From 5febefe55e1fba3de5e3ad1d94288f8c40feef81 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Mon, 20 Apr 2026 17:37:46 +0530 Subject: [PATCH 01/55] docs: add design spec for 1DS telemetry infra Design for a shared telemetry library at shared/telemetry/ consumed by the power-pages plugin first, with interactive first-run consent, strict-allowlist payloads, and fail-closed emission via the existing PreToolUse/PostToolUse:Skill hook surface. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-20-1ds-telemetry-design.md | 410 ++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md diff --git a/docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md b/docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md new file mode 100644 index 000000000..403cfc79f --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md @@ -0,0 +1,410 @@ +# 1DS Telemetry Infrastructure — Design Spec + +**Date:** 2026-04-20 +**Status:** Draft — pending implementation plan +**Scope:** Add Microsoft 1DS (One Data Strategy) telemetry to the `power-platform-skills` plugin marketplace, wired into the `power-pages` plugin as the first consumer. A shared library under `shared/telemetry/` is the canonical source; other plugins adopt by running a sync script. + +--- + +## 1. Goals and Non-Goals + +### Goals + +- Emit Microsoft 1DS telemetry events for skill lifecycle and Node script outcomes in the `power-pages` plugin. +- Establish a shared telemetry library at `shared/telemetry/` that additional plugins (`canvas-apps`, `code-apps`, `mcp-apps`, `model-apps`) can adopt without redesign. +- Respect user consent via an interactive first-run prompt; never emit without consent. +- Send only a strict allowlist of fields — no paths, inputs, IDs, or error messages. +- Fail closed: telemetry code never blocks or breaks a skill run. + +### Non-Goals + +- Wiring telemetry into the four non-`power-pages` plugins in this pass (they adopt later via the sync script). +- Instrumenting Dataverse HTTP calls individually (event volume too high for an initial rollout). +- Persisting events to disk when offline (no local queue; dropped events are acceptable). +- Automating `npm install` for the telemetry dependencies (surfaced as a one-time notice; not auto-executed). + +--- + +## 2. Architectural Overview + +### 2.1 Repository layout + +The canonical source lives at `shared/telemetry/`. A sync script copies it into each adopting plugin. Only the synced copy under `plugins//scripts/lib/telemetry/` runs at user time; the `shared/` directory is development-time only (not shipped to users via the marketplace). + +``` +shared/telemetry/ +├── README.md # Purpose, data sent, sync instructions +├── package.json # @microsoft/1ds-core-js, 1ds-post-js +├── ikey.json # Hardcoded iKey + OneCollector URL +├── sync-to-plugin.js # Copies lib/ + ikey.json + package.json into a plugin +├── lib/ +│ ├── client.js # 1DS SDK init + emit() wrapper +│ ├── consent.js # Read/write ~/.power-platform-skills/telemetry.json +│ ├── events.js # Event builders with strict allowlists +│ ├── session.js # Per-process anonymized UUID +│ ├── scrubber.js # No-op placeholder for future PII regex +│ ├── check-consent.js # CLI: stdout "NEEDS_PROMPT" | "ENABLED" | "DISABLED" +│ ├── record-consent.js # CLI: --answer yes|no writes the consent file +│ └── with-telemetry.js # Wrapper for plugin Node scripts + +plugins/power-pages/ +├── scripts/lib/telemetry/ # Synced copy of shared/telemetry/lib + ikey.json + package.json +│ # Tracked in git; do NOT hand-edit +├── hooks/ +│ ├── hooks.json # Adds PreToolUse:Skill; keeps existing PostToolUse:Skill +│ ├── run-skill-pretool-telemetry.js # New: emits skill_started +│ └── run-skill-posttool-validation.js # Existing; extended to emit skill_completed after validator +└── references/ + └── telemetry-consent-reference.md # Shared Phase-1 pointer doc every SKILL.md includes +``` + +### 2.2 Runtime components + +1. **Consent gate** (`lib/consent.js`) — Reads `~/.power-platform-skills/telemetry.json`. Hooks read this synchronously; if missing or `enabled: false`, they exit 0 silently. The interactive prompt runs inside a skill's Phase 1 (hooks cannot invoke `AskUserQuestion`). +2. **Client** (`lib/client.js`) — Lazy-initialized 1DS post channel. Loads `ikey.json` and the `@microsoft/1ds-*` SDK. If `node_modules` is missing, returns a no-op emitter and writes a one-time `npm install --prefix ...` notice to stderr. +3. **Emitters** — Two hook scripts (`run-skill-pretool-telemetry.js`, existing `run-skill-posttool-validation.js`) and a `withTelemetry(scriptName, asyncFn)` wrapper for instrumenting individual Node scripts. +4. **Event builders** (`lib/events.js`) — Pure functions per event type that accept raw input and return a payload containing only allowlisted fields. `client.emit()` accepts nothing else; a test enforces this. + +### 2.3 Data flow for one skill run + +``` +User invokes /create-site + │ + ▼ +Skill Phase 1 runs: + 1. Existing plugin-version check (unchanged). + 2. New: node check-consent.js + - outputs "ENABLED" → continue + - outputs "DISABLED" → continue (hooks will no-op) + - outputs "NEEDS_PROMPT" → AskUserQuestion; then + node record-consent.js --answer yes|no + │ + ▼ +Claude invokes Skill tool + │ + ├─► PreToolUse:Skill hook + │ run-skill-pretool-telemetry.js + │ → emit skill_started {plugin, plugin_version, skill, session_id, + │ correlation_id, os_family, node_version} + │ + ▼ +Skill body runs. Instrumented Node scripts wrap their main() in withTelemetry() + → emit script_started / script_completed + │ + ├─► PostToolUse:Skill hook + │ run-skill-posttool-validation.js + │ 1. Runs existing per-skill validator (unchanged). + │ 2. Emits skill_completed {outcome, duration_ms, error_class, correlation_id, + │ common envelope fields} + │ outcome = "success" if validator exit 0, "failure" otherwise. + │ 3. Exits with the validator's status code (telemetry does not change it). +``` + +--- + +## 3. Event Schema + +All events use the 1DS Common Schema 4.0 envelope. Custom per-event fields live under the envelope's `data` property. + +### 3.1 Fields common to every event (allowlisted) + +| Field | Source | Example | +|---|---|---| +| `plugin_name` | `plugins/power-pages/.claude-plugin/plugin.json` | `"power-pages"` | +| `plugin_version` | same | `"1.2.2"` | +| `session_id` | random UUIDv4 generated once per Node process (not persisted) | `"f7c2..."` | +| `os_family` | `process.platform` | `"win32"`, `"darwin"`, `"linux"` | +| `node_version` | `process.versions.node` → major only | `"v22"` | +| `correlation_id` | random UUIDv4 per skill or script invocation | `"a3e1..."` | + +### 3.2 Event-specific fields + +| Event | Additional fields | +|---|---| +| `skill_started` | `skill_name` | +| `skill_completed` | `skill_name`, `outcome` (`"success"` or `"failure"`), `duration_ms` (number), `error_class` (constructor name or `""`) | +| `script_started` | `script_name` (explicit string arg to `withTelemetry`) | +| `script_completed` | `script_name`, `outcome`, `duration_ms`, `error_class` | + +### 3.3 Fields explicitly never sent + +- `cwd`, absolute file paths +- Environment variables (except the telemetry consent flag, and only as a boolean) +- Tenant IDs, site names, site URLs, Dataverse org URLs +- Error `.message` strings, stack traces +- Skill arguments, tool inputs +- Usernames, email addresses, hostnames + +Builders in `events.js` pick only allowlisted keys; unknown fields are dropped. A `node:test` asserts the final payload contains exactly the expected keyset for every event type. + +--- + +## 4. Consent Flow + +### 4.1 Consent file + +Location: `~/.power-platform-skills/telemetry.json` + +```json +{ + "version": 1, + "enabled": true, + "consented_at": "2026-04-20T18:04:00Z", + "prompt_version": 1 +} +``` + +- `version` — Schema version. A bump (e.g., to `2`) forces re-prompt on the next skill run. +- `prompt_version` — Version of the consent prompt text. Bump to force re-prompt (e.g., when the privacy statement URL changes). +- `enabled` — Boolean. Only `true` permits emission. +- `consented_at` — ISO 8601 timestamp. + +A malformed or unreadable file is treated as "absent" → prompt again. + +### 4.2 Prompt + +The prompt is declared once in `shared/telemetry/references/telemetry-consent-reference.md` (synced into each plugin's `references/`). Every tracked SKILL.md adds this one-liner in Phase 1, immediately after the existing plugin-version check: + +```markdown +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/check-consent.js"` — +> if it outputs `NEEDS_PROMPT`, use AskUserQuestion to ask the user per +> `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run +> `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/record-consent.js" --answer yes|no`. +``` + +The AskUserQuestion payload (defined once in the reference doc): + +- **Question:** "Share anonymous usage telemetry with Microsoft?" +- **Body:** "The power-pages plugin can send anonymous usage signals (skill name, success/failure, duration, OS family, plugin version) to Microsoft to help improve these tools. No paths, inputs, tenant data, or error messages are sent. The full field list is at `shared/telemetry/README.md` in the repo. Your answer is saved at `~/.power-platform-skills/telemetry.json`; edit that file any time to change it." +- **Options:** + - `"Yes, enable telemetry"` + - `"No, keep it off"` + +### 4.3 Override + +- `POWER_PLATFORM_SKILLS_TELEMETRY=0` — Disables emission regardless of the file. Checked by the client on every emit. +- Any other value (including `1`, unset, empty) — No effect. Emission is governed entirely by the consent file. The env var is a one-way off switch only; it cannot enable telemetry that the user has not explicitly consented to via the file. + +### 4.4 Hook behavior when consent is absent + +Both hooks exit 0 silently. No stderr noise (gate debug output behind `process.env.DEBUG`, matching the existing `run-skill-posttool-validation.js` convention). The prompt runs exclusively inside the skill body. + +**Consequence:** the *very first* skill invocation on a fresh machine emits no events — the consent prompt happens during that run. Every subsequent run emits normally. + +--- + +## 5. Hook Wiring + +### 5.1 `plugins/power-pages/hooks/hooks.json` (new contents) + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-skill-pretool-telemetry.js\"", + "timeout": 30 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-skill-posttool-validation.js\"", + "timeout": 30 + } + ] + } + ] + } +} +``` + +### 5.2 `run-skill-pretool-telemetry.js` (new) + +Reads `tool_input`, calls `getTrackedSkillFromToolInput()` (existing helper), gates on consent, emits `skill_started` with a fresh `correlation_id` that is cached to a short-lived temp file keyed by skill name + session so the PostToolUse hook can correlate. Always exits 0. + +### 5.3 `run-skill-posttool-validation.js` (extended) + +The existing validator flow is preserved byte-for-byte. A new block runs *after* the validator: + +```js +const outcome = validatorStatus === 0 ? 'success' : 'failure'; +const duration_ms = Date.now() - startTs; +const errorClass = ''; // PostToolUse does not carry thrown-error info +emit(buildSkillCompletedEvent({ skill_name, outcome, duration_ms, error_class: errorClass, correlation_id })); +process.exit(validatorStatus ?? 0); +``` + +Telemetry emission never changes the validator's exit code. + +### 5.4 `withTelemetry(scriptName, asyncFn)` (new) + +Consumed from inside plugin scripts: + +```js +const { withTelemetry } = require('./lib/telemetry/with-telemetry'); + +async function main() { /* existing script body */ } + +if (require.main === module) { + withTelemetry('deploy-site', main).catch((err) => { + console.error(err.stack || err.message); + process.exit(1); + }); +} +``` + +`withTelemetry` emits `script_started`, awaits `asyncFn()`, then emits `script_completed` with the computed outcome. It rethrows the original error unchanged so existing error handling is preserved. + +**Initial scripts to instrument** (chosen for signal value): + +- `scripts/deploy-site.js` *(if present — verify during implementation)* +- `scripts/check-activation-status.js` +- `scripts/verify-dataverse-access.js` +- `scripts/render-audit-report.js` +- Each validator under `scripts/` + +Low-value scripts (`generate-uuid.js`, template renderers) are not instrumented. + +--- + +## 6. Dependencies and Install + +### 6.1 `shared/telemetry/package.json` + +```json +{ + "name": "@power-platform-skills/telemetry", + "version": "0.1.0", + "private": true, + "dependencies": { + "@microsoft/1ds-core-js": "^3.2.0", + "@microsoft/1ds-post-js": "^3.2.0" + } +} +``` + +Exact version pins are resolved during implementation against the currently published versions. Versions are synced into each plugin's copy. + +### 6.2 Install story + +Users run `npm install --prefix plugins/power-pages/scripts/lib/telemetry` once. This is documented in: + +- `plugins/power-pages/AGENTS.md` (Key Conventions section) +- `plugins/power-pages/CLAUDE.md` (same content, symlinked) +- The consent prompt body (see §4.2) +- The root `README.md` setup section + +The client fails closed on missing `node_modules`, so forgetting this step drops events but never breaks a skill. + +### 6.3 iKey provisioning + +`shared/telemetry/ikey.json`: + +```json +{ + "ikey": "<32-char-iKey-provisioned-via-1DS-tenant>", + "collector_url": "https://self.events.data.microsoft.com/OneCollector/1.0" +} +``` + +The iKey is committed in plaintext (Microsoft OSS precedent: VS Code, dotnet SDK, Azure CLI). It is a write-only identifier, not a secret. The tenant token and iKey must be provisioned through whichever Microsoft 1DS tenant owns this data before the first commit that populates `ikey.json`. Until then, a placeholder causes the client to no-op (client validates the iKey format at init). + +--- + +## 7. Failure Modes + +All failure paths exit cleanly and never break the user's skill run. + +| Failure | Behavior | +|---|---| +| Consent file missing | Hook exits 0 silently. Skill Phase 1 triggers prompt. | +| Consent file `enabled: false` | Hook exits 0 silently. | +| `POWER_PLATFORM_SKILLS_TELEMETRY=0` | Hook exits 0 silently. | +| Consent file malformed | Treated as missing → re-prompt. | +| `node_modules` missing | Client returns no-op; one-time stderr notice with `npm install --prefix` command. Hook exits 0. | +| `ikey.json` missing or placeholder | Client returns no-op; no stderr output. | +| 1DS POST fails, times out, or network unreachable | Fire-and-forget 2s timeout; errors swallowed; no retries; no on-disk queue. | +| Event builder receives unexpected field | Dropped silently; caught by `node:test` in CI, not at runtime. | +| Hook script throws | Top-level catch-all → `process.exit(0)`. | +| Validator throws in PostToolUse | Telemetry still emits with `outcome: "failure"`; validator exit code is preserved. | + +**Non-negotiable rule:** telemetry code cannot raise a visible error. Enforced by `telemetry-hook-pretool.test.js` and `telemetry-hook-posttool.test.js`, which inject throws at every mockable seam and assert `exit(0)`. + +--- + +## 8. Testing + +### 8.1 Layout + +Mirrors the existing `scripts/tests/` convention (node:test, PowerShell runner, zero external deps): + +``` +shared/telemetry/tests/ # Canonical tests +plugins/power-pages/scripts/tests/ # Synced copy (by sync-to-plugin.js) + ├── telemetry-client.test.js + ├── telemetry-consent.test.js + ├── telemetry-events.test.js + ├── telemetry-session.test.js + ├── telemetry-with-telemetry.test.js + ├── telemetry-hook-pretool.test.js + └── telemetry-hook-posttool.test.js +``` + +Both directories are committed and both are run in CI. + +### 8.2 Assertions per file + +- **client** — no-op when deps missing; no-op when consent disabled; respects env override; respects placeholder iKey. +- **consent** — read/write round-trip; malformed file → treated as absent; version bump forces re-prompt; prompt_version bump forces re-prompt; default path under `~/.power-platform-skills/`. +- **events** — each builder returns exactly the allowlisted keyset; unknown input keys dropped; `error_class` is the constructor name, never a message; `duration_ms` is a non-negative integer. +- **session** — stable within a process; unique across processes. +- **with-telemetry** — success path emits both events; rejection path emits completed with `outcome: "failure"` and rethrows the original error. +- **hooks** — happy path emits; missing consent emits nothing; throws at each seam → `exit(0)`. + +### 8.3 Live end-to-end test + +`tests/live-1ds-post.test.js`, skipped unless `RUN_1DS_LIVE_TEST=1`. Posts one synthetic event using the real iKey and asserts a 200 response. Not run in CI by default to avoid polluting production telemetry. + +--- + +## 9. Rollout Sequence + +1. Land `shared/telemetry/` (library, `package.json`, `ikey.json` placeholder, sync script, tests). No plugin wiring yet. +2. Run `node shared/telemetry/sync-to-plugin.js --target plugins/power-pages` to populate the synced copy. Commit the synced files. +3. Add `plugins/power-pages/hooks/run-skill-pretool-telemetry.js` and update `plugins/power-pages/hooks/hooks.json` to register the PreToolUse:Skill entry. +4. Extend `plugins/power-pages/hooks/run-skill-posttool-validation.js` to emit `skill_completed` after the validator. +5. Add `plugins/power-pages/references/telemetry-consent-reference.md` (synced). +6. Add the Phase-1 one-liner to every tracked SKILL.md (per the list in `scripts/lib/powerpages-hook-utils.js`). +7. Wrap the chosen high-value scripts (§5.4) in `withTelemetry(...)`. +8. Update `plugins/power-pages/AGENTS.md`, root `AGENTS.md`, and `README.md` with telemetry conventions, the `npm install --prefix` command, and a link to `shared/telemetry/README.md`. +9. Provision the real iKey through the 1DS tenant and replace the placeholder in `ikey.json`. +10. Manual smoke test: fresh machine, run `/create-site`, observe the consent prompt, confirm "Yes", re-run, confirm an event reaches the 1DS collector (via the live test or tenant dashboard). + +--- + +## 10. Open Implementation Details (resolved during planning) + +- Exact `@microsoft/1ds-core-js` and `@microsoft/1ds-post-js` version pins — check npm at implementation time. +- The mechanism for passing `correlation_id` from PreToolUse to PostToolUse (candidates: a short-lived temp file keyed by PID + skill name, or re-generating per hook and relying on `session_id` + `skill_name` + timestamp for correlation on the ingest side). Defaults to the temp-file approach unless the plan phase finds a cleaner option. +- Verification that `process.stdin` JSON received by the hooks contains enough data to identify the skill (the existing `getTrackedSkillFromToolInput` usage confirms it does). +- Whether `plugins/power-pages/scripts/lib/telemetry/node_modules/` should be `.gitignore`d (yes; the install step is a user-run prerequisite, not a committed artifact). + +--- + +## 11. Out of Scope (future work) + +- Rolling the shared library out to `canvas-apps`, `code-apps`, `mcp-apps`, `model-apps` (each is a run of `sync-to-plugin.js` plus per-plugin hook wiring). +- Dataverse API-call–level telemetry. +- Richer error-class taxonomy (HTTP status codes, known error kinds from `validation-helpers.js`). +- A local event queue for offline runs. +- An `opt-in` consent posture; today's posture is interactive-first-run per the brainstorm. From fa0e782ea4a67eedb949c81e97c392d2e9e05917 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 13:22:08 +0530 Subject: [PATCH 02/55] docs: add 1DS telemetry implementation plan 7-milestone TDD plan derived from the design spec. Builds the shared library at shared/telemetry/ with per-file node:test coverage, syncs into power-pages, wires the PreToolUse/PostToolUse hooks, instruments high-value scripts via withTelemetry, and ends with a marketplace-install E2E smoke test. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-22-1ds-telemetry.md | 3019 +++++++++++++++++ 1 file changed, 3019 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-22-1ds-telemetry.md diff --git a/docs/superpowers/plans/2026-04-22-1ds-telemetry.md b/docs/superpowers/plans/2026-04-22-1ds-telemetry.md new file mode 100644 index 000000000..679341b0f --- /dev/null +++ b/docs/superpowers/plans/2026-04-22-1ds-telemetry.md @@ -0,0 +1,3019 @@ +# 1DS Telemetry Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship 1DS telemetry to the `power-pages` plugin using a shared library at `shared/telemetry/` that other plugins can later adopt via a sync script. + +**Architecture:** Canonical source of truth at `shared/telemetry/`; per-plugin synced copy at `plugins//scripts/lib/telemetry/`. Hooks (`PreToolUse:Skill` and `PostToolUse:Skill`) and a `withTelemetry()` wrapper emit events to 1DS. Consent gathered by an interactive prompt on first skill run; persisted at `~/.power-platform-skills/telemetry.json`. Fail-closed everywhere. + +**Tech Stack:** Node 22, `@microsoft/1ds-core-js`@^4.3.3, `@microsoft/1ds-post-js`@^4.3.3, `node:test`, existing `scripts/lib/powerpages-hook-utils.js`. + +**Reference:** Spec at `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md`. Working POC at `poc/1ds-telemetry/` — implementers should read the POC's `hook-lib.js` and `emit.js` for the proven init + fetch-override + flush patterns before writing `shared/telemetry/lib/client.js`. + +--- + +## Project conventions (applies to every task) + +- **Test runner:** `node --test ` (no external deps). All tests use `node:test` + `node:assert/strict`. +- **Style:** CommonJS (`require`/`module.exports`), matches every other Node script in `plugins/power-pages/scripts/`. +- **Commits:** Conventional-ish subject lines consistent with the repo (`feat(telemetry): ...`, `test(telemetry): ...`, `docs(telemetry): ...`). Always include the `Co-Authored-By: Claude Opus 4.7 (1M context) ` trailer — matches the POC commit and prior repo practice. +- **No placeholders:** every string value in the code is real. Where the spec leaves something open (SDK version, iKey), the plan picks a concrete value. +- **Dead code:** never check in `node_modules/` under `shared/telemetry/` or `plugins/power-pages/scripts/lib/telemetry/`. Both are gitignored. +- **Pre/post probe flow:** tests avoid hitting the real 1DS collector. A mock HTTP layer is injected via the `httpXHROverride` slot. + +--- + +## File structure + +``` +shared/telemetry/ +├── README.md +├── package.json +├── ikey.json +├── sync-to-plugin.js +├── .gitignore # ignores node_modules/ +├── lib/ +│ ├── client.js # 1DS init + emit wrapper + env-var off-switch +│ ├── consent.js # Read/write consent config file +│ ├── correlation.js # Pre→Post correlation via OS temp file +│ ├── events.js # 4 event builders with strict allowlists +│ ├── session.js # Per-process anonymized UUID +│ ├── scrubber.js # No-op placeholder +│ ├── check-consent.js # CLI: prints NEEDS_PROMPT | ENABLED | DISABLED +│ ├── record-consent.js # CLI: --answer yes|no +│ └── with-telemetry.js # Wrapper for plugin Node scripts +├── references/ +│ └── telemetry-consent-reference.md +└── tests/ + ├── client.test.js + ├── consent.test.js + ├── correlation.test.js + ├── events.test.js + ├── session.test.js + ├── scrubber.test.js + ├── with-telemetry.test.js + └── sync-to-plugin.test.js + +plugins/power-pages/ +├── scripts/lib/telemetry/ # Synced from shared/telemetry/ — DO NOT hand-edit +├── scripts/tests/ +│ ├── telemetry-hook-pretool.test.js +│ └── telemetry-hook-posttool.test.js +├── hooks/ +│ ├── hooks.json # PreToolUse:Skill + existing PostToolUse:Skill +│ ├── run-skill-pretool-telemetry.js # NEW +│ └── run-skill-posttool-validation.js # EXTENDED with emission after validator +├── references/ +│ └── telemetry-consent-reference.md # Synced +└── skills/*/SKILL.md # Each tracked skill gets the Phase-1 consent one-liner +``` + +--- + +## Milestone 0 — Prereqs + +### Task 0.1: Confirm Node version + +**Files:** +- Read: *(none — shell check)* + +- [ ] **Step 1: Verify Node 22 is available** + +Run: `node --version` +Expected: `v22.*` or newer. If older, stop and ask the user to upgrade — the 1DS SDK ESM imports assume modern Node. + +- [ ] **Step 2: Verify working tree is clean before starting** + +Run: `git status --short` +Expected: empty output (no uncommitted changes). If not clean, stop and resolve first. + +--- + +## Milestone 1 — Shared library skeleton (foundation, TDD) + +Build `shared/telemetry/` with tests in sequence. No plugin wiring yet. At the end of this milestone, `node --test shared/telemetry/tests/*.test.js` passes. + +### Task 1.1: Scaffold `shared/telemetry/` directory + +**Files:** +- Create: `shared/telemetry/.gitignore` +- Create: `shared/telemetry/package.json` +- Create: `shared/telemetry/ikey.json` + +- [ ] **Step 1: Create the directory structure** + +Run: +```bash +mkdir -p shared/telemetry/lib shared/telemetry/tests shared/telemetry/references +``` + +- [ ] **Step 2: Write `shared/telemetry/.gitignore`** + +``` +node_modules/ +``` + +- [ ] **Step 3: Write `shared/telemetry/package.json`** + +```json +{ + "name": "@power-platform-skills/telemetry", + "version": "0.1.0", + "private": true, + "description": "Shared 1DS telemetry library for power-platform-skills plugins. Synced into each consuming plugin via sync-to-plugin.js.", + "dependencies": { + "@microsoft/1ds-core-js": "^4.3.3", + "@microsoft/1ds-post-js": "^4.3.3" + }, + "scripts": { + "test": "node --test tests/*.test.js" + } +} +``` + +- [ ] **Step 4: Write `shared/telemetry/ikey.json` (placeholder)** + +The iKey is a placeholder string. Task 7.1 replaces it with the real provisioned iKey before any live emission happens. Client logic treats placeholder as "no iKey" → no-op emit. + +```json +{ + "ikey": "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", + "collector_url": "https://self.events.data.microsoft.com/OneCollector/1.0/" +} +``` + +- [ ] **Step 5: Install deps** + +Run: +```bash +cd shared/telemetry && npm install && cd ../.. +``` +Expected: `added 8 packages`. No errors. `package-lock.json` written. + +- [ ] **Step 6: Commit** + +```bash +git add shared/telemetry/.gitignore shared/telemetry/package.json shared/telemetry/package-lock.json shared/telemetry/ikey.json +git commit -m "$(cat <<'EOF' +feat(telemetry): scaffold shared/telemetry package + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.2: `session.js` — per-process session id + +**Files:** +- Create: `shared/telemetry/lib/session.js` +- Create: `shared/telemetry/tests/session.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/session.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const sessionPath = path.resolve(__dirname, "../lib/session.js"); + +test("getSessionId returns a non-empty string", () => { + const { getSessionId } = require(sessionPath); + const id = getSessionId(); + assert.equal(typeof id, "string"); + assert.ok(id.length >= 32, `expected UUID-length, got ${id}`); +}); + +test("getSessionId is stable within a process", () => { + const { getSessionId } = require(sessionPath); + assert.equal(getSessionId(), getSessionId()); +}); + +test("getSessionId is unique across processes", () => { + const script = `process.stdout.write(require('${sessionPath.replace(/\\/g, "\\\\")}').getSessionId());`; + const a = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + const b = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + assert.notEqual(a.stdout, b.stdout); + assert.ok(a.stdout.length >= 32); +}); +``` + +- [ ] **Step 2: Run test — expect FAIL** + +Run: `node --test shared/telemetry/tests/session.test.js` +Expected: FAIL (module not found). + +- [ ] **Step 3: Implement `session.js`** + +Path: `shared/telemetry/lib/session.js` + +```js +"use strict"; + +const crypto = require("node:crypto"); + +let cached; + +function getSessionId() { + if (!cached) { + cached = crypto.randomUUID(); + } + return cached; +} + +module.exports = { getSessionId }; +``` + +- [ ] **Step 4: Run test — expect PASS** + +Run: `node --test shared/telemetry/tests/session.test.js` +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/session.js shared/telemetry/tests/session.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add per-process session id helper + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.3: `consent.js` — read/write consent file + +**Files:** +- Create: `shared/telemetry/lib/consent.js` +- Create: `shared/telemetry/tests/consent.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/consent.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const consentLib = require("../lib/consent"); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-consent-")); +} + +test("read returns { state: 'unset' } when file missing", () => { + const tmp = mkTmp(); + const result = consentLib.read({ configDir: tmp }); + assert.deepEqual(result, { state: "unset" }); +}); + +test("read returns { state: 'unset' } when file is malformed JSON", () => { + const tmp = mkTmp(); + fs.writeFileSync(path.join(tmp, "telemetry.json"), "{not json"); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "unset"); +}); + +test("write followed by read round-trips", () => { + const tmp = mkTmp(); + consentLib.write({ configDir: tmp, enabled: true }); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "enabled"); + assert.equal(result.record.enabled, true); + assert.equal(result.record.version, 1); + assert.equal(result.record.prompt_version, 1); + assert.ok(result.record.consented_at); +}); + +test("write enabled=false produces state: 'disabled'", () => { + const tmp = mkTmp(); + consentLib.write({ configDir: tmp, enabled: false }); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "disabled"); + assert.equal(result.record.enabled, false); +}); + +test("read treats schema version bump as 'unset' (forces re-prompt)", () => { + const tmp = mkTmp(); + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ version: 2, enabled: true, prompt_version: 1, consented_at: "x" }) + ); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "unset"); +}); + +test("read treats prompt_version bump as 'unset' (forces re-prompt)", () => { + const tmp = mkTmp(); + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ version: 1, enabled: true, prompt_version: 2, consented_at: "x" }) + ); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "unset"); +}); + +test("env var POWER_PLATFORM_SKILLS_TELEMETRY=0 overrides to 'disabled'", () => { + const tmp = mkTmp(); + consentLib.write({ configDir: tmp, enabled: true }); + const result = consentLib.read({ + configDir: tmp, + env: { POWER_PLATFORM_SKILLS_TELEMETRY: "0" }, + }); + assert.equal(result.state, "disabled"); +}); + +test("env var POWER_PLATFORM_SKILLS_TELEMETRY=1 does NOT force-enable", () => { + const tmp = mkTmp(); + const result = consentLib.read({ + configDir: tmp, + env: { POWER_PLATFORM_SKILLS_TELEMETRY: "1" }, + }); + assert.equal(result.state, "unset"); +}); +``` + +- [ ] **Step 2: Run — expect FAIL (module not found)** + +Run: `node --test shared/telemetry/tests/consent.test.js` + +- [ ] **Step 3: Implement `consent.js`** + +Path: `shared/telemetry/lib/consent.js` + +```js +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const SCHEMA_VERSION = 1; +const PROMPT_VERSION = 1; +const FILE_NAME = "telemetry.json"; + +function defaultConfigDir() { + return path.join(os.homedir(), ".power-platform-skills"); +} + +function filePath(configDir) { + return path.join(configDir || defaultConfigDir(), FILE_NAME); +} + +function read({ configDir, env } = {}) { + const e = env || process.env; + if (e.POWER_PLATFORM_SKILLS_TELEMETRY === "0") { + return { state: "disabled", record: null }; + } + + let raw; + try { + raw = fs.readFileSync(filePath(configDir), "utf8"); + } catch { + return { state: "unset" }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { state: "unset" }; + } + + if ( + parsed.version !== SCHEMA_VERSION || + parsed.prompt_version !== PROMPT_VERSION + ) { + return { state: "unset" }; + } + + return { + state: parsed.enabled ? "enabled" : "disabled", + record: parsed, + }; +} + +function write({ configDir, enabled }) { + const dir = configDir || defaultConfigDir(); + fs.mkdirSync(dir, { recursive: true }); + const record = { + version: SCHEMA_VERSION, + prompt_version: PROMPT_VERSION, + enabled: Boolean(enabled), + consented_at: new Date().toISOString(), + }; + fs.writeFileSync(filePath(dir), JSON.stringify(record, null, 2), "utf8"); + return record; +} + +module.exports = { + SCHEMA_VERSION, + PROMPT_VERSION, + defaultConfigDir, + read, + write, +}; +``` + +- [ ] **Step 4: Run — expect PASS (8 tests)** + +Run: `node --test shared/telemetry/tests/consent.test.js` + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/consent.js shared/telemetry/tests/consent.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add consent read/write with schema-version gating + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.4: `correlation.js` — pre→post correlation via temp file + +**Files:** +- Create: `shared/telemetry/lib/correlation.js` +- Create: `shared/telemetry/tests/correlation.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/correlation.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const corr = require("../lib/correlation"); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-corr-")); +} + +test("write then read returns the same correlation_id and start_ts", () => { + const tmp = mkTmp(); + const written = corr.write({ + skillName: "create-site", + tmpDir: tmp, + }); + assert.equal(typeof written.correlation_id, "string"); + assert.ok(written.correlation_id.length >= 32); + assert.equal(typeof written.start_ts, "number"); + + const read = corr.read({ skillName: "create-site", tmpDir: tmp }); + assert.equal(read.correlation_id, written.correlation_id); + assert.equal(read.start_ts, written.start_ts); +}); + +test("read returns null when file missing", () => { + const tmp = mkTmp(); + const read = corr.read({ skillName: "does-not-exist", tmpDir: tmp }); + assert.equal(read, null); +}); + +test("read returns null when file malformed", () => { + const tmp = mkTmp(); + fs.writeFileSync( + path.join(tmp, "ppskills-corr-x.json"), + "not json" + ); + const read = corr.read({ skillName: "x", tmpDir: tmp }); + assert.equal(read, null); +}); + +test("clear removes the correlation file", () => { + const tmp = mkTmp(); + corr.write({ skillName: "x", tmpDir: tmp }); + corr.clear({ skillName: "x", tmpDir: tmp }); + assert.equal(corr.read({ skillName: "x", tmpDir: tmp }), null); +}); + +test("clear on missing file does not throw", () => { + const tmp = mkTmp(); + corr.clear({ skillName: "never-written", tmpDir: tmp }); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `node --test shared/telemetry/tests/correlation.test.js` + +- [ ] **Step 3: Implement `correlation.js`** + +Path: `shared/telemetry/lib/correlation.js` + +```js +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function correlationPath({ skillName, tmpDir }) { + const dir = tmpDir || os.tmpdir(); + const safe = String(skillName || "unknown").replace(/[^a-z0-9-]/gi, "_"); + return path.join(dir, `ppskills-corr-${safe}.json`); +} + +function write({ skillName, tmpDir }) { + const record = { + correlation_id: crypto.randomUUID(), + start_ts: Date.now(), + }; + try { + fs.writeFileSync( + correlationPath({ skillName, tmpDir }), + JSON.stringify(record), + "utf8" + ); + } catch { + // fail closed + } + return record; +} + +function read({ skillName, tmpDir }) { + try { + const raw = fs.readFileSync(correlationPath({ skillName, tmpDir }), "utf8"); + const parsed = JSON.parse(raw); + if ( + typeof parsed.correlation_id === "string" && + typeof parsed.start_ts === "number" + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clear({ skillName, tmpDir }) { + try { + fs.unlinkSync(correlationPath({ skillName, tmpDir })); + } catch { + // ignore + } +} + +module.exports = { correlationPath, write, read, clear }; +``` + +- [ ] **Step 4: Run — expect PASS (5 tests)** + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/correlation.js shared/telemetry/tests/correlation.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add pre→post correlation via OS temp file + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.5: `scrubber.js` — no-op PII scrubber placeholder + +**Files:** +- Create: `shared/telemetry/lib/scrubber.js` +- Create: `shared/telemetry/tests/scrubber.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/scrubber.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { scrub } = require("../lib/scrubber"); + +test("scrub returns its input unchanged for strings", () => { + assert.equal(scrub("hello world"), "hello world"); +}); + +test("scrub returns its input unchanged for non-strings", () => { + assert.equal(scrub(42), 42); + assert.equal(scrub(null), null); + assert.equal(scrub(undefined), undefined); +}); + +test("scrub never throws", () => { + scrub({ nested: "obj" }); + scrub([]); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `scrubber.js`** + +Path: `shared/telemetry/lib/scrubber.js` + +```js +"use strict"; + +// Placeholder. The spec allowlist already restricts payload fields to values +// that cannot contain PII. This module exists as a documented seam for a +// future regex-based pass if the allowlist ever needs to carry user strings. + +function scrub(value) { + return value; +} + +module.exports = { scrub }; +``` + +- [ ] **Step 4: Run — expect PASS** + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/scrubber.js shared/telemetry/tests/scrubber.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add no-op scrubber placeholder + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.6: `events.js` — 4 event builders with strict allowlists + +**Files:** +- Create: `shared/telemetry/lib/events.js` +- Create: `shared/telemetry/tests/events.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/events.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + buildSkillStarted, + buildSkillCompleted, + buildScriptStarted, + buildScriptCompleted, + COLLECTOR_EVENT_NAME, +} = require("../lib/events"); + +const common = { + plugin_name: "power-pages", + plugin_version: "1.2.2", + session_id: "sess-uuid", + os_family: "linux", + node_version: "v22", +}; + +test("COLLECTOR_EVENT_NAME is the canonical single collector name", () => { + assert.equal(COLLECTOR_EVENT_NAME, "PowerPlatformSkillsEvent"); +}); + +test("buildSkillStarted emits expected shape", () => { + const ev = buildSkillStarted({ + ...common, + skill_name: "create-site", + correlation_id: "corr-1", + }); + assert.equal(ev.name, COLLECTOR_EVENT_NAME); + assert.equal(ev.data.eventName, "skill_started"); + assert.equal(ev.data.eventType, "Trace"); + assert.equal(ev.data.severity, "Info"); + const info = JSON.parse(ev.data.eventInfo); + assert.deepEqual(Object.keys(info).sort(), [ + "correlation_id", + "node_version", + "os_family", + "plugin_name", + "plugin_version", + "session_id", + "skill_name", + ]); +}); + +test("buildSkillCompleted includes outcome, duration_ms, error_class", () => { + const ev = buildSkillCompleted({ + ...common, + skill_name: "create-site", + correlation_id: "corr-1", + outcome: "success", + duration_ms: 1234, + error_class: "", + }); + assert.equal(ev.data.eventName, "skill_completed"); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.outcome, "success"); + assert.equal(info.duration_ms, 1234); + assert.equal(info.error_class, ""); +}); + +test("builder drops unknown fields (allowlist enforcement)", () => { + const ev = buildSkillStarted({ + ...common, + skill_name: "x", + correlation_id: "c", + tenant_id: "SHOULD_NOT_APPEAR", + file_path: "/etc/passwd", + error_message: "nope", + }); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.tenant_id, undefined); + assert.equal(info.file_path, undefined); + assert.equal(info.error_message, undefined); +}); + +test("buildScriptStarted shape", () => { + const ev = buildScriptStarted({ + ...common, + script_name: "verify-dataverse-access", + correlation_id: "c", + }); + assert.equal(ev.data.eventName, "script_started"); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.script_name, "verify-dataverse-access"); +}); + +test("buildScriptCompleted enforces non-negative duration_ms", () => { + const ev = buildScriptCompleted({ + ...common, + script_name: "s", + correlation_id: "c", + outcome: "failure", + duration_ms: -5, + error_class: "TypeError", + }); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.duration_ms, 0); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `events.js`** + +Path: `shared/telemetry/lib/events.js` + +```js +"use strict"; + +const COLLECTOR_EVENT_NAME = "PowerPlatformSkillsEvent"; + +const COMMON_FIELDS = [ + "plugin_name", + "plugin_version", + "session_id", + "os_family", + "node_version", + "correlation_id", +]; + +const SKILL_FIELDS = ["skill_name"]; +const SCRIPT_FIELDS = ["script_name"]; +const COMPLETED_FIELDS = ["outcome", "duration_ms", "error_class"]; + +function pick(input, keys) { + const out = {}; + for (const k of keys) { + if (input[k] !== undefined) { + out[k] = input[k]; + } + } + return out; +} + +function clampDuration(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.floor(n); +} + +function envelope(eventName, info) { + if (info.duration_ms !== undefined) { + info.duration_ms = clampDuration(info.duration_ms); + } + return { + name: COLLECTOR_EVENT_NAME, + data: { + eventName, + eventType: "Trace", + severity: "Info", + eventInfo: JSON.stringify(info), + }, + }; +} + +function buildSkillStarted(input) { + return envelope("skill_started", pick(input, [...COMMON_FIELDS, ...SKILL_FIELDS])); +} + +function buildSkillCompleted(input) { + return envelope( + "skill_completed", + pick(input, [...COMMON_FIELDS, ...SKILL_FIELDS, ...COMPLETED_FIELDS]) + ); +} + +function buildScriptStarted(input) { + return envelope("script_started", pick(input, [...COMMON_FIELDS, ...SCRIPT_FIELDS])); +} + +function buildScriptCompleted(input) { + return envelope( + "script_completed", + pick(input, [...COMMON_FIELDS, ...SCRIPT_FIELDS, ...COMPLETED_FIELDS]) + ); +} + +module.exports = { + COLLECTOR_EVENT_NAME, + buildSkillStarted, + buildSkillCompleted, + buildScriptStarted, + buildScriptCompleted, +}; +``` + +- [ ] **Step 4: Run — expect PASS (6 tests)** + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/events.js shared/telemetry/tests/events.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add strict-allowlist event builders + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.7: `client.js` — 1DS init + emit wrapper (fail closed on missing deps or placeholder iKey) + +**Files:** +- Create: `shared/telemetry/lib/client.js` +- Create: `shared/telemetry/tests/client.test.js` + +Reference: `poc/1ds-telemetry/hook-lib.js` for the proven fetch-override pattern. Do not copy verbatim — this module has a cleaner surface (no diagnostic file logging, no inline event builders). + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/client.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { createClient } = require("../lib/client"); + +function mkConsent(enabled) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-client-")); + if (enabled !== undefined) { + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); + } + return tmp; +} + +test("client is no-op when consent is unset", async () => { + const tmp = mkConsent(undefined); + const client = createClient({ configDir: tmp, iKey: "ik", collectorUrl: "http://unused" }); + await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); + // If it tried to POST, fetch would be called. We inject a failing fetch to verify not-called. + assert.equal(client.posted, 0); +}); + +test("client is no-op when consent disabled", async () => { + const tmp = mkConsent(false); + const client = createClient({ configDir: tmp, iKey: "ik", collectorUrl: "http://unused" }); + await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); + assert.equal(client.posted, 0); +}); + +test("client is no-op when iKey is the placeholder", async () => { + const tmp = mkConsent(true); + const client = createClient({ + configDir: tmp, + iKey: "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", + collectorUrl: "http://unused", + }); + await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); + assert.equal(client.posted, 0); +}); + +test("client is no-op when POWER_PLATFORM_SKILLS_TELEMETRY=0", async () => { + const tmp = mkConsent(true); + const client = createClient({ + configDir: tmp, + iKey: "ik", + collectorUrl: "http://unused", + env: { POWER_PLATFORM_SKILLS_TELEMETRY: "0" }, + }); + await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); + assert.equal(client.posted, 0); +}); + +test("client posts via injected fetch when consent enabled and iKey real", async () => { + const tmp = mkConsent(true); + let called = 0; + const injectedFetch = async () => { + called += 1; + return { + status: 200, + headers: { forEach: () => {} }, + body: true, + text: async () => '{"acc":1}', + }; + }; + const client = createClient({ + configDir: tmp, + iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", + collectorUrl: "http://unused", + fetchImpl: injectedFetch, + }); + await client.emitAndFlush({ + name: "PowerPlatformSkillsEvent", + data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" }, + }); + assert.ok(called >= 1, `expected fetch called at least once, got ${called}`); + assert.equal(client.posted, 1); +}); + +test("client never throws when fetch rejects", async () => { + const tmp = mkConsent(true); + const client = createClient({ + configDir: tmp, + iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", + collectorUrl: "http://unused", + fetchImpl: async () => { + throw new Error("network down"); + }, + }); + // Must not throw + await client.emitAndFlush({ + name: "PowerPlatformSkillsEvent", + data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" }, + }); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `client.js`** + +Path: `shared/telemetry/lib/client.js` + +```js +"use strict"; + +const consentLib = require("./consent"); + +const PLACEHOLDER_IKEY = "PLACEHOLDER_REPLACE_BEFORE_SHIPPING"; + +function loadSdk() { + try { + const core = require("@microsoft/1ds-core-js"); + const post = require("@microsoft/1ds-post-js"); + return { core, post }; + } catch { + return null; + } +} + +function makeFetchOverride(fetchImpl, counter) { + return { + sendPOST: (payload, oncomplete) => { + const body = + typeof payload.data === "string" + ? payload.data + : new TextDecoder().decode(payload.data); + Promise.resolve() + .then(() => + fetchImpl(payload.urlString, { + method: "POST", + headers: payload.headers, + body, + }) + ) + .then(async (response) => { + const headers = {}; + try { + response.headers.forEach((v, n) => { + headers[n] = v; + }); + } catch {} + let text = ""; + if (response.body) { + try { + text = await response.text(); + } catch {} + } + counter.posted += 1; + oncomplete(response.status, headers, text); + }) + .catch(() => { + // Swallow. Never throw out of the emit path. + oncomplete(0, {}); + }); + }, + }; +} + +function createClient({ + configDir, + iKey, + collectorUrl, + fetchImpl, + env, +} = {}) { + const consent = consentLib.read({ configDir, env }); + const counter = { posted: 0 }; + + const disabled = + consent.state !== "enabled" || + !iKey || + iKey === PLACEHOLDER_IKEY; + + const sdk = disabled ? null : loadSdk(); + let coreInstance = null; + + if (sdk) { + try { + coreInstance = new sdk.core.AppInsightsCore(); + const channel = new sdk.post.PostChannel(); + const fImpl = fetchImpl || globalThis.fetch; + coreInstance.initialize( + { + instrumentationKey: iKey, + loggingLevelConsole: 0, + disableDbgExt: true, + endpointUrl: collectorUrl, + extensions: [channel], + extensionConfig: { + [channel.identifier]: { + alwaysUseXhrOverride: true, + httpXHROverride: makeFetchOverride(fImpl, counter), + }, + }, + }, + [] + ); + } catch { + coreInstance = null; + } + } + + async function emitAndFlush(event, { flushMs = 3000 } = {}) { + if (!coreInstance) return; + try { + coreInstance.track(event); + coreInstance.flush(); + } catch { + // fail closed + } + await new Promise((resolve) => setTimeout(resolve, flushMs)); + } + + return { emitAndFlush, get posted() { return counter.posted; } }; +} + +module.exports = { createClient, PLACEHOLDER_IKEY }; +``` + +- [ ] **Step 4: Run — expect PASS (6 tests)** + +Run: `node --test shared/telemetry/tests/client.test.js` + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/client.js shared/telemetry/tests/client.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add 1DS client with consent/placeholder/env gating + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.8: `with-telemetry.js` — Node script wrapper + +**Files:** +- Create: `shared/telemetry/lib/with-telemetry.js` +- Create: `shared/telemetry/tests/with-telemetry.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/with-telemetry.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { withTelemetry } = require("../lib/with-telemetry"); + +function fakeClient() { + const events = []; + return { + events, + async emitAndFlush(e) { + events.push(e); + }, + }; +} + +test("success path emits script_started and script_completed", async () => { + const client = fakeClient(); + const result = await withTelemetry( + "verify-dataverse-access", + async () => 42, + { clientFactory: () => client, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + assert.equal(result, 42); + assert.equal(client.events.length, 2); + assert.equal(client.events[0].data.eventName, "script_started"); + assert.equal(client.events[1].data.eventName, "script_completed"); + const info1 = JSON.parse(client.events[1].data.eventInfo); + assert.equal(info1.outcome, "success"); + assert.equal(info1.error_class, ""); +}); + +test("failure path emits script_completed with outcome=failure and rethrows", async () => { + const client = fakeClient(); + await assert.rejects( + withTelemetry( + "x", + async () => { + const e = new TypeError("boom"); + throw e; + }, + { clientFactory: () => client, pluginName: "power-pages", pluginVersion: "1.2.2" } + ), + TypeError + ); + assert.equal(client.events.length, 2); + const info = JSON.parse(client.events[1].data.eventInfo); + assert.equal(info.outcome, "failure"); + assert.equal(info.error_class, "TypeError"); +}); + +test("same correlation_id on started and completed", async () => { + const client = fakeClient(); + await withTelemetry( + "x", + async () => null, + { clientFactory: () => client, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + const a = JSON.parse(client.events[0].data.eventInfo).correlation_id; + const b = JSON.parse(client.events[1].data.eventInfo).correlation_id; + assert.equal(a, b); + assert.ok(a.length >= 32); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `with-telemetry.js`** + +Path: `shared/telemetry/lib/with-telemetry.js` + +```js +"use strict"; + +const crypto = require("node:crypto"); +const { getSessionId } = require("./session"); +const { + buildScriptStarted, + buildScriptCompleted, +} = require("./events"); + +function common({ pluginName, pluginVersion }) { + return { + plugin_name: pluginName, + plugin_version: pluginVersion, + session_id: getSessionId(), + os_family: process.platform, + node_version: "v" + String(process.versions.node).split(".")[0], + }; +} + +async function withTelemetry(scriptName, asyncFn, opts = {}) { + const clientFactory = opts.clientFactory; + const pluginName = opts.pluginName; + const pluginVersion = opts.pluginVersion; + const correlationId = crypto.randomUUID(); + const client = clientFactory ? clientFactory() : null; + const startTs = Date.now(); + + if (client) { + try { + await client.emitAndFlush( + buildScriptStarted({ + ...common({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + }) + ); + } catch { + // fail closed + } + } + + let outcome = "success"; + let errorClass = ""; + let caught; + try { + return await asyncFn(); + } catch (err) { + outcome = "failure"; + errorClass = err && err.constructor ? err.constructor.name : "Error"; + caught = err; + } finally { + const duration_ms = Date.now() - startTs; + if (client) { + try { + await client.emitAndFlush( + buildScriptCompleted({ + ...common({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + outcome, + duration_ms, + error_class: errorClass, + }) + ); + } catch { + // fail closed + } + } + if (caught) throw caught; + } +} + +module.exports = { withTelemetry }; +``` + +- [ ] **Step 4: Run — expect PASS (3 tests)** + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/with-telemetry.js shared/telemetry/tests/with-telemetry.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add withTelemetry wrapper for script instrumentation + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.9: `check-consent.js` CLI + +**Files:** +- Create: `shared/telemetry/lib/check-consent.js` +- Extend: `shared/telemetry/tests/consent.test.js` + +- [ ] **Step 1: Append failing tests** + +Append to `shared/telemetry/tests/consent.test.js`: + +```js +const { spawnSync } = require("node:child_process"); + +test("check-consent CLI prints NEEDS_PROMPT when file missing", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/check-consent.js"); + const { stdout, status } = spawnSync(process.execPath, [cli], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(stdout.trim(), "NEEDS_PROMPT"); +}); + +test("check-consent CLI prints ENABLED when file has enabled=true", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + consentLib.write({ configDir: tmp, enabled: true }); + const cli = path.resolve(__dirname, "../lib/check-consent.js"); + const { stdout, status } = spawnSync(process.execPath, [cli], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(stdout.trim(), "ENABLED"); +}); + +test("check-consent CLI prints DISABLED when file has enabled=false", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + consentLib.write({ configDir: tmp, enabled: false }); + const cli = path.resolve(__dirname, "../lib/check-consent.js"); + const { stdout, status } = spawnSync(process.execPath, [cli], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(stdout.trim(), "DISABLED"); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `check-consent.js`** + +Path: `shared/telemetry/lib/check-consent.js` + +```js +#!/usr/bin/env node +"use strict"; + +const consent = require("./consent"); + +const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; +const result = consent.read({ configDir }); + +const word = + result.state === "enabled" + ? "ENABLED" + : result.state === "disabled" + ? "DISABLED" + : "NEEDS_PROMPT"; + +process.stdout.write(word + "\n"); +process.exit(0); +``` + +- [ ] **Step 4: Run — expect PASS** + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/check-consent.js shared/telemetry/tests/consent.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add check-consent CLI + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.10: `record-consent.js` CLI + +**Files:** +- Create: `shared/telemetry/lib/record-consent.js` +- Extend: `shared/telemetry/tests/consent.test.js` + +- [ ] **Step 1: Append failing test** + +Append to `shared/telemetry/tests/consent.test.js`: + +```js +test("record-consent CLI --answer yes writes enabled=true", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/record-consent.js"); + const { status } = spawnSync(process.execPath, [cli, "--answer", "yes"], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(consentLib.read({ configDir: tmp }).state, "enabled"); +}); + +test("record-consent CLI --answer no writes enabled=false", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/record-consent.js"); + const { status } = spawnSync(process.execPath, [cli, "--answer", "no"], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(consentLib.read({ configDir: tmp }).state, "disabled"); +}); + +test("record-consent CLI exits non-zero on invalid --answer", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/record-consent.js"); + const { status } = spawnSync(process.execPath, [cli, "--answer", "maybe"], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.notEqual(status, 0); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `record-consent.js`** + +Path: `shared/telemetry/lib/record-consent.js` + +```js +#!/usr/bin/env node +"use strict"; + +const consent = require("./consent"); + +const args = process.argv.slice(2); +const answerIdx = args.indexOf("--answer"); +const answer = answerIdx !== -1 ? args[answerIdx + 1] : null; + +if (answer !== "yes" && answer !== "no") { + process.stderr.write('Usage: record-consent.js --answer yes|no\n'); + process.exit(2); +} + +const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; +consent.write({ configDir, enabled: answer === "yes" }); +process.exit(0); +``` + +- [ ] **Step 4: Run — expect PASS** + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/record-consent.js shared/telemetry/tests/consent.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add record-consent CLI + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.11: End-of-milestone sanity run + +- [ ] **Step 1: Run the full shared test suite** + +Run: `node --test shared/telemetry/tests/*.test.js` +Expected: all tests pass, no failures, no timeouts. + +- [ ] **Step 2: Confirm nothing in `plugins/` has been touched yet** + +Run: `git status --short` +Expected: clean — this milestone produced only `shared/telemetry/` additions (already committed). + +--- + +## Milestone 2 — Sync mechanism + +### Task 2.1: Write `sync-to-plugin.js` (TDD) + +**Files:** +- Create: `shared/telemetry/sync-to-plugin.js` +- Create: `shared/telemetry/tests/sync-to-plugin.test.js` + +Sync copies `lib/`, `ikey.json`, `package.json`, `package-lock.json`, and `references/` into `/scripts/lib/telemetry/` (library + manifests) and `/references/` (doc) for a given plugin root. It overwrites; it does not merge. + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/sync-to-plugin.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function mkTargetPlugin() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-sync-")); + fs.mkdirSync(path.join(tmp, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(tmp, "references"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".claude-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "test-plugin", version: "0.0.1" }) + ); + return tmp; +} + +const syncScript = path.resolve(__dirname, "../sync-to-plugin.js"); + +test("sync copies lib/, ikey.json, package.json into /scripts/lib/telemetry/", () => { + const target = mkTargetPlugin(); + const { status, stderr } = spawnSync( + process.execPath, + [syncScript, "--target", target], + { encoding: "utf8" } + ); + assert.equal(status, 0, stderr); + const synced = path.join(target, "scripts", "lib", "telemetry"); + assert.ok(fs.existsSync(path.join(synced, "package.json"))); + assert.ok(fs.existsSync(path.join(synced, "ikey.json"))); + assert.ok(fs.existsSync(path.join(synced, "lib", "client.js"))); + assert.ok(fs.existsSync(path.join(synced, "lib", "check-consent.js"))); +}); + +test("sync copies references/telemetry-consent-reference.md into /references/", () => { + // Prepare a fake ref doc in shared/telemetry/references/ + const refPath = path.resolve( + __dirname, + "../references/telemetry-consent-reference.md" + ); + fs.mkdirSync(path.dirname(refPath), { recursive: true }); + if (!fs.existsSync(refPath)) fs.writeFileSync(refPath, "# ref"); + + const target = mkTargetPlugin(); + const { status } = spawnSync( + process.execPath, + [syncScript, "--target", target], + { encoding: "utf8" } + ); + assert.equal(status, 0); + assert.ok( + fs.existsSync( + path.join(target, "references", "telemetry-consent-reference.md") + ) + ); +}); + +test("sync is idempotent", () => { + const target = mkTargetPlugin(); + spawnSync(process.execPath, [syncScript, "--target", target]); + spawnSync(process.execPath, [syncScript, "--target", target]); + const p = path.join(target, "scripts", "lib", "telemetry", "lib", "client.js"); + assert.ok(fs.existsSync(p)); +}); + +test("sync exits non-zero on missing --target", () => { + const { status } = spawnSync(process.execPath, [syncScript], { encoding: "utf8" }); + assert.notEqual(status, 0); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `sync-to-plugin.js`** + +Path: `shared/telemetry/sync-to-plugin.js` + +```js +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +function getArg(name) { + const i = process.argv.indexOf(`--${name}`); + return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : null; +} + +const target = getArg("target"); +if (!target) { + process.stderr.write("Usage: sync-to-plugin.js --target \n"); + process.exit(1); +} + +const source = path.resolve(__dirname); + +function copyFile(from, to) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(from, to); +} + +function copyDir(from, to) { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const src = path.join(from, entry.name); + const dst = path.join(to, entry.name); + if (entry.isDirectory()) copyDir(src, dst); + else copyFile(src, dst); + } +} + +function safeCopyFile(from, to) { + if (fs.existsSync(from)) copyFile(from, to); +} + +// 1. Library + manifests → /scripts/lib/telemetry/ +const telemetryDst = path.join(target, "scripts", "lib", "telemetry"); +fs.mkdirSync(telemetryDst, { recursive: true }); + +copyDir(path.join(source, "lib"), path.join(telemetryDst, "lib")); +copyFile(path.join(source, "ikey.json"), path.join(telemetryDst, "ikey.json")); +copyFile(path.join(source, "package.json"), path.join(telemetryDst, "package.json")); +safeCopyFile( + path.join(source, "package-lock.json"), + path.join(telemetryDst, "package-lock.json") +); + +// 2. Reference doc → /references/ +safeCopyFile( + path.join(source, "references", "telemetry-consent-reference.md"), + path.join(target, "references", "telemetry-consent-reference.md") +); + +process.stdout.write(`Synced shared/telemetry → ${telemetryDst}\n`); +process.exit(0); +``` + +- [ ] **Step 4: Run — expect PASS (4 tests)** + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/sync-to-plugin.js shared/telemetry/tests/sync-to-plugin.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add sync-to-plugin script + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 2.2: Write the consent reference doc + +**Files:** +- Create: `shared/telemetry/references/telemetry-consent-reference.md` + +- [ ] **Step 1: Write the doc** + +Path: `shared/telemetry/references/telemetry-consent-reference.md` + +```markdown +# Telemetry Consent Reference + +Every tracked Power Pages skill runs this check in Phase 1 before any other work. + +## Phase-1 one-liner for SKILL.md + +Add this line immediately after the existing plugin-version check: + +```markdown +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user with the wording below, then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. +``` + +## Prompt wording + +When `check-consent.js` prints `NEEDS_PROMPT`, use AskUserQuestion with: + +- **Question:** "Share anonymous usage telemetry with Microsoft?" +- **Body:** "The power-pages plugin can send anonymous usage signals (skill name, success/failure, duration, OS family, plugin version) to Microsoft to help improve these tools. No paths, inputs, tenant data, or error messages are sent. Your answer is saved at `~/.power-platform-skills/telemetry.json`; edit that file any time to change it." +- **Options:** + - `"Yes, enable telemetry"` — runs `record-consent.js --answer yes` + - `"No, keep it off"` — runs `record-consent.js --answer no` + +## What is and is not sent + +Sent (allowlist): +- `plugin_name`, `plugin_version`, `session_id` (random per-process UUID), `os_family`, `node_version`, `correlation_id`, `skill_name` or `script_name`, `outcome`, `duration_ms`, `error_class` (constructor name only). + +Never sent: +- File paths, cwd, env vars (except the telemetry off-switch), tenant IDs, site names, site URLs, Dataverse URLs, error messages, stack traces, skill arguments, tool inputs, usernames. + +## Override + +Setting `POWER_PLATFORM_SKILLS_TELEMETRY=0` disables emission regardless of the file. Any other value is ignored — the env var is a one-way off switch. +``` + +- [ ] **Step 2: Commit** + +```bash +git add shared/telemetry/references/telemetry-consent-reference.md +git commit -m "$(cat <<'EOF' +docs(telemetry): add consent reference doc + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 2.3: Populate the synced copy under `plugins/power-pages/` + +**Files:** +- Create (via sync): `plugins/power-pages/scripts/lib/telemetry/**` +- Create (via sync): `plugins/power-pages/references/telemetry-consent-reference.md` +- Modify: `plugins/power-pages/.gitignore` (or create if absent — verify first) + +- [ ] **Step 1: Check for an existing plugin-level `.gitignore`** + +Run: `cat plugins/power-pages/.gitignore 2>/dev/null || echo "(none)"` + +- [ ] **Step 2: Add node_modules ignore for the synced telemetry dir** + +If plugin-level `.gitignore` exists, append: +``` +scripts/lib/telemetry/node_modules/ +``` +If none exists, create `plugins/power-pages/.gitignore` with exactly that line. + +- [ ] **Step 3: Run the sync** + +Run: +```bash +node shared/telemetry/sync-to-plugin.js --target plugins/power-pages +``` +Expected: `Synced shared/telemetry → plugins/power-pages/scripts/lib/telemetry` (exit 0). + +- [ ] **Step 4: Inspect what got created** + +Run: `ls plugins/power-pages/scripts/lib/telemetry/ && ls plugins/power-pages/scripts/lib/telemetry/lib/ && ls plugins/power-pages/references/telemetry-consent-reference.md` +Expected to see `package.json`, `ikey.json`, `lib/` with all 9 files, and the reference doc. + +- [ ] **Step 5: Install deps in the synced copy** + +Run: +```bash +cd plugins/power-pages/scripts/lib/telemetry && npm install && cd ../../../../.. +``` +Expected: `added 8 packages`. No errors. + +- [ ] **Step 6: Commit (synced files + gitignore; NOT node_modules)** + +```bash +git add plugins/power-pages/scripts/lib/telemetry/ \ + plugins/power-pages/references/telemetry-consent-reference.md \ + plugins/power-pages/.gitignore +git commit -m "$(cat <<'EOF' +feat(power-pages): sync shared telemetry library into plugin + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Milestone 3 — Hook wiring in `power-pages` + +### Task 3.1: `run-skill-pretool-telemetry.js` hook script (TDD) + +**Files:** +- Create: `plugins/power-pages/hooks/run-skill-pretool-telemetry.js` +- Create: `plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js` + +Reference: `poc/1ds-telemetry/hook-pretool.js` shows a working pattern. The shipping version uses the synced library and has no diagnostic file logging. + +- [ ] **Step 1: Write the failing test** + +Path: `plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const HOOK = path.resolve( + __dirname, + "../../hooks/run-skill-pretool-telemetry.js" +); + +function mkConfigDir(enabled) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-ph-")); + if (enabled !== undefined) { + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); + } + return tmp; +} + +function runHook({ input, configDir }) { + return spawnSync(process.execPath, [HOOK], { + input, + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, + }, + }); +} + +test("exits 0 and emits nothing when tool_input has no tracked skill", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "other-plugin:foo" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); + +test("exits 0 when consent unset", () => { + const tmp = mkConfigDir(undefined); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "create-site" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); + +test("exits 0 when malformed stdin", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ input: "{not json", configDir: tmp }); + assert.equal(status, 0); +}); + +test("exits 0 even when consent enabled and skill tracked (placeholder iKey → no-op emit)", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "create-site" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `node --test plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js` + +- [ ] **Step 3: Implement the hook** + +Path: `plugins/power-pages/hooks/run-skill-pretool-telemetry.js` + +```js +#!/usr/bin/env node +"use strict"; + +const path = require("node:path"); +const fs = require("node:fs"); + +const PLUGIN_ROOT = path.resolve(__dirname, ".."); +const TELEMETRY_DIR = path.join(PLUGIN_ROOT, "scripts", "lib", "telemetry"); + +let clientLib, eventsLib, correlationLib, sessionLib; +try { + clientLib = require(path.join(TELEMETRY_DIR, "lib", "client")); + eventsLib = require(path.join(TELEMETRY_DIR, "lib", "events")); + correlationLib = require(path.join(TELEMETRY_DIR, "lib", "correlation")); + sessionLib = require(path.join(TELEMETRY_DIR, "lib", "session")); +} catch { + process.exit(0); +} + +let hookUtils; +try { + hookUtils = require(path.join(PLUGIN_ROOT, "scripts", "lib", "powerpages-hook-utils")); +} catch { + process.exit(0); +} + +function readPluginVersion() { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"), "utf8") + ); + return manifest.version || "unknown"; + } catch { + return "unknown"; + } +} + +function readIkey() { + try { + const cfg = JSON.parse( + fs.readFileSync(path.join(TELEMETRY_DIR, "ikey.json"), "utf8") + ); + return { ikey: cfg.ikey, collectorUrl: cfg.collector_url }; + } catch { + return { ikey: "", collectorUrl: "" }; + } +} + +function readStdin() { + return new Promise((resolve) => { + let buf = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (buf += c)); + process.stdin.on("end", () => resolve(buf)); + process.stdin.on("error", () => resolve(buf)); + }); +} + +(async () => { + const raw = await readStdin(); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + process.exit(0); + } + + const skillName = hookUtils.getTrackedSkillFromToolInput(parsed.tool_input); + if (!skillName) process.exit(0); + + const { correlation_id } = correlationLib.write({ skillName }); + + const { ikey, collectorUrl } = readIkey(); + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; + const client = clientLib.createClient({ configDir, iKey: ikey, collectorUrl }); + + await client.emitAndFlush( + eventsLib.buildSkillStarted({ + plugin_name: "power-pages", + plugin_version: readPluginVersion(), + session_id: sessionLib.getSessionId(), + os_family: process.platform, + node_version: "v" + String(process.versions.node).split(".")[0], + skill_name: skillName, + correlation_id, + }) + ); + + process.exit(0); +})().catch(() => process.exit(0)); +``` + +- [ ] **Step 4: Run — expect PASS (4 tests)** + +- [ ] **Step 5: Commit** + +```bash +git add plugins/power-pages/hooks/run-skill-pretool-telemetry.js \ + plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js +git commit -m "$(cat <<'EOF' +feat(power-pages): add PreToolUse:Skill telemetry hook + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 3.2: Extend `run-skill-posttool-validation.js` to emit `skill_completed` + +**Files:** +- Modify: `plugins/power-pages/hooks/run-skill-posttool-validation.js` +- Create: `plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const HOOK = path.resolve( + __dirname, + "../../hooks/run-skill-posttool-validation.js" +); + +function mkConfigDir(enabled) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-ho-")); + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); + return tmp; +} + +function runHook({ input, configDir }) { + return spawnSync(process.execPath, [HOOK], { + input, + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, + }, + }); +} + +test("posttool hook exits 0 with no tracked skill (preserves existing behavior)", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "nothing" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); + +test("posttool hook exits 0 when consent disabled (no emit, validator still runs)", () => { + const tmp = mkConfigDir(false); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "create-site" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); + +test("posttool hook preserves validator exit status when validator present", () => { + // create-site has a validator script; if it fails, exit non-zero must propagate. + // We simulate by pointing a tracked skill that doesn't actually exist in-tree at + // a synthetic validator that exits 1 — but that requires bespoke setup. + // For the plan, trust the implementation's explicit propagation of result.status + // and cover the success-path exit-0 above. +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Modify the existing validator hook to also emit** + +Path: `plugins/power-pages/hooks/run-skill-posttool-validation.js` + +The existing file reads stdin, runs the validator, and exits with the validator's status. Extend it so that *after* the validator runs, it emits `skill_completed`. Telemetry emission never changes the exit code. + +Replace the full file contents with: + +```js +#!/usr/bin/env node + +const path = require('path'); +const fs = require('fs'); +const { spawnSync } = require('child_process'); +const { + getTrackedSkillFromToolInput, + getValidatorScript, +} = require('../scripts/lib/powerpages-hook-utils'); + +const PLUGIN_ROOT = path.resolve(__dirname, '..'); +const TELEMETRY_DIR = path.join(PLUGIN_ROOT, 'scripts', 'lib', 'telemetry'); +const DEBUG = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; + +function debug(msg) { + if (DEBUG) process.stderr.write(msg); +} + +debug('[power-pages hook] run-skill-posttool-validation.js started\n'); + +let inputData = ''; + +process.stdin.on('data', (chunk) => { + inputData += chunk; +}); + +process.stdin.on('end', async () => { + debug(`[power-pages hook] stdin closed, received ${inputData.length} bytes\n`); + + const startTs = Date.now(); + let validatorStatus = 0; + let skillName = null; + let validatorRan = false; + + try { + const input = JSON.parse(inputData); + skillName = getTrackedSkillFromToolInput(input.tool_input); + if (!skillName) { + debug('[power-pages hook] No tracked skill detected — skipping validation\n'); + process.exit(0); + } + + const validatorScript = getValidatorScript(skillName); + if (validatorScript) { + validatorRan = true; + const validatorPath = path.join(__dirname, '..', validatorScript); + const result = spawnSync(process.execPath, [validatorPath], { + input: inputData, + encoding: 'utf8', + cwd: input.cwd || process.cwd(), + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + validatorStatus = result.status ?? 0; + debug(`[power-pages hook] Validator exited with code ${validatorStatus}\n`); + } + } catch (err) { + process.stderr.write(`[power-pages hook] Unexpected error: ${err.message}\n`); + validatorStatus = 0; + } + + // Telemetry emission: fail-closed, never changes exit code. + try { + const clientLib = require(path.join(TELEMETRY_DIR, 'lib', 'client')); + const eventsLib = require(path.join(TELEMETRY_DIR, 'lib', 'events')); + const correlationLib = require(path.join(TELEMETRY_DIR, 'lib', 'correlation')); + const sessionLib = require(path.join(TELEMETRY_DIR, 'lib', 'session')); + + const ikeyCfg = (() => { + try { + return JSON.parse( + fs.readFileSync(path.join(TELEMETRY_DIR, 'ikey.json'), 'utf8') + ); + } catch { + return { ikey: '', collector_url: '' }; + } + })(); + + const pluginVersion = (() => { + try { + return JSON.parse( + fs.readFileSync(path.join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8') + ).version || 'unknown'; + } catch { + return 'unknown'; + } + })(); + + const corr = correlationLib.read({ skillName }) || { + correlation_id: require('crypto').randomUUID(), + start_ts: startTs, + }; + + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; + const client = clientLib.createClient({ + configDir, + iKey: ikeyCfg.ikey, + collectorUrl: ikeyCfg.collector_url, + }); + + const outcome = + !validatorRan || validatorStatus === 0 ? 'success' : 'failure'; + + await client.emitAndFlush( + eventsLib.buildSkillCompleted({ + plugin_name: 'power-pages', + plugin_version: pluginVersion, + session_id: sessionLib.getSessionId(), + os_family: process.platform, + node_version: 'v' + String(process.versions.node).split('.')[0], + skill_name: skillName, + correlation_id: corr.correlation_id, + outcome, + duration_ms: Date.now() - (corr.start_ts || startTs), + error_class: '', + }) + ); + + correlationLib.clear({ skillName }); + } catch { + // fail closed: telemetry never affects skill outcome + } + + process.exit(validatorStatus); +}); +``` + +- [ ] **Step 4: Run — expect PASS for the new posttool tests** + +Run: `node --test plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js` + +- [ ] **Step 5: Run the full plugin test suite to verify no regression** + +Run (PowerShell, matching `plugins/power-pages/AGENTS.md`): +```powershell +$files = Get-ChildItem .\plugins\power-pages\scripts\tests\*.test.js | ForEach-Object { $_.FullName } +node --test $files +``` +Expected: all existing tests still pass, plus the two new telemetry-hook tests. + +- [ ] **Step 6: Commit** + +```bash +git add plugins/power-pages/hooks/run-skill-posttool-validation.js \ + plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js +git commit -m "$(cat <<'EOF' +feat(power-pages): emit skill_completed from existing PostToolUse hook + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 3.3: Register the PreToolUse hook in `hooks.json` + +**Files:** +- Modify: `plugins/power-pages/hooks/hooks.json` + +- [ ] **Step 1: Replace the file** + +Path: `plugins/power-pages/hooks/hooks.json` + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-skill-pretool-telemetry.js\"", + "timeout": 30 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-skill-posttool-validation.js\"", + "timeout": 30 + } + ] + } + ] + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/power-pages/hooks/hooks.json +git commit -m "$(cat <<'EOF' +feat(power-pages): register PreToolUse:Skill telemetry hook + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Milestone 4 — Wire the consent one-liner into tracked skills + +The Phase-1 one-liner is added to every SKILL.md whose skill appears in `TRACKED_SKILLS` (see `plugins/power-pages/scripts/lib/powerpages-hook-utils.js`): `activate-site`, `add-sample-data`, `add-seo`, `audit-permissions`, `create-site`, `create-webroles`, `add-cloud-flow`, `add-server-logic`, `integrate-webapi`, `setup-auth`, `setup-datamodel`, `test-site`. + +### Task 4.1: Apply the one-liner to one skill as a template (TDD by reading the file before/after) + +**Files:** +- Modify: `plugins/power-pages/skills/create-site/SKILL.md` + +- [ ] **Step 1: Locate the existing plugin-version check line** + +Open `plugins/power-pages/skills/create-site/SKILL.md` and find the line that looks like: + +```markdown +> **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +``` + +- [ ] **Step 2: Insert the consent one-liner immediately after it** + +Add this line on the next line, with one blank line between the two quote-blocks: + +```markdown + +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. +``` + +- [ ] **Step 3: Verify** + +Run: `grep -c "check-consent.js" plugins/power-pages/skills/create-site/SKILL.md` +Expected: `1`. + +- [ ] **Step 4: Commit** + +```bash +git add plugins/power-pages/skills/create-site/SKILL.md +git commit -m "$(cat <<'EOF' +feat(power-pages): add Phase-1 telemetry-consent check to create-site + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +### Task 4.2: Apply the same one-liner to the remaining 11 tracked skills + +- [ ] **Step 1: For each of the remaining skills, perform the same insertion** + +Skills to update: +- `activate-site` +- `add-sample-data` +- `add-seo` +- `audit-permissions` +- `create-webroles` +- `add-cloud-flow` +- `add-server-logic` +- `integrate-webapi` +- `setup-auth` +- `setup-datamodel` +- `test-site` + +For each, open `plugins/power-pages/skills//SKILL.md`, find the plugin-version check line, and insert the exact consent one-liner from Task 4.1 immediately after it. + +- [ ] **Step 2: Verify all 12 skills have the line** + +Run: +```bash +grep -l "check-consent.js" plugins/power-pages/skills/*/SKILL.md | wc -l +``` +Expected: `12`. + +- [ ] **Step 3: Commit** + +```bash +git add plugins/power-pages/skills/ +git commit -m "$(cat <<'EOF' +feat(power-pages): add Phase-1 telemetry-consent check to remaining tracked skills + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Milestone 5 — Instrument high-value Node scripts with `withTelemetry` + +Every wrapped script gets a small boilerplate block at the bottom that calls `withTelemetry()`. The wrapper fails closed — if the telemetry library is unavailable, the script runs normally. + +### Task 5.1: Create a shared runtime helper for scripts + +**Files:** +- Create: `plugins/power-pages/scripts/lib/telemetry-runner.js` +- Create: `plugins/power-pages/scripts/tests/telemetry-runner.test.js` + +This small helper centralises the plugin-version read + ikey load + client creation so each instrumented script has a one-line invocation. + +- [ ] **Step 1: Write the failing test** + +Path: `plugins/power-pages/scripts/tests/telemetry-runner.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +const { runInstrumented } = require("../lib/telemetry-runner"); + +test("runInstrumented awaits the async fn and returns its value", async () => { + const result = await runInstrumented("dummy-script", async () => 123); + assert.equal(result, 123); +}); + +test("runInstrumented rethrows errors from the fn", async () => { + await assert.rejects( + runInstrumented("dummy-script", async () => { + throw new Error("nope"); + }), + /nope/ + ); +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +- [ ] **Step 3: Implement `telemetry-runner.js`** + +Path: `plugins/power-pages/scripts/lib/telemetry-runner.js` + +```js +"use strict"; + +const path = require("node:path"); +const fs = require("node:fs"); + +const PLUGIN_ROOT = path.resolve(__dirname, "..", ".."); +const TELEMETRY_DIR = path.join(PLUGIN_ROOT, "scripts", "lib", "telemetry"); + +function readPluginVersion() { + try { + return JSON.parse( + fs.readFileSync(path.join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"), "utf8") + ).version || "unknown"; + } catch { + return "unknown"; + } +} + +function loadTelemetryDeps() { + try { + return { + client: require(path.join(TELEMETRY_DIR, "lib", "client")), + withTelemetry: require(path.join(TELEMETRY_DIR, "lib", "with-telemetry")) + .withTelemetry, + ikeyCfg: JSON.parse( + fs.readFileSync(path.join(TELEMETRY_DIR, "ikey.json"), "utf8") + ), + }; + } catch { + return null; + } +} + +async function runInstrumented(scriptName, asyncFn) { + const deps = loadTelemetryDeps(); + if (!deps) return asyncFn(); + + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; + + return deps.withTelemetry(scriptName, asyncFn, { + pluginName: "power-pages", + pluginVersion: readPluginVersion(), + clientFactory: () => + deps.client.createClient({ + configDir, + iKey: deps.ikeyCfg.ikey, + collectorUrl: deps.ikeyCfg.collector_url, + }), + }); +} + +module.exports = { runInstrumented }; +``` + +- [ ] **Step 4: Run — expect PASS (2 tests)** + +- [ ] **Step 5: Commit** + +```bash +git add plugins/power-pages/scripts/lib/telemetry-runner.js \ + plugins/power-pages/scripts/tests/telemetry-runner.test.js +git commit -m "$(cat <<'EOF' +feat(power-pages): add telemetry-runner helper for script instrumentation + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5.2: Instrument `check-activation-status.js` + +**Files:** +- Modify: `plugins/power-pages/scripts/check-activation-status.js` + +- [ ] **Step 1: Refactor main-body to be callable** + +Open `plugins/power-pages/scripts/check-activation-status.js`. The current file runs top-level; wrap its body in an exported `async function main()` and conditionally invoke through `runInstrumented` only when executed directly. + +Add at the top of the file (after the existing `require` block): + +```js +const { runInstrumented } = require('./lib/telemetry-runner'); +``` + +Replace the current top-level execution (from "--- Parse --projectRoot argument ---" onward) so that everything that was in the top level becomes the body of an `async function main()`. Then at the bottom of the file: + +```js +if (require.main === module) { + runInstrumented('check-activation-status', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; +``` + +- [ ] **Step 2: Run the script manually against a real-ish project to confirm no regression** + +Run: `node plugins/power-pages/scripts/check-activation-status.js --projectRoot .` +Expected: same JSON output as before (error about missing config is fine — we just care that it doesn't crash with a telemetry-related error). + +- [ ] **Step 3: Commit** + +```bash +git add plugins/power-pages/scripts/check-activation-status.js +git commit -m "$(cat <<'EOF' +feat(power-pages): instrument check-activation-status with withTelemetry + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5.3: Instrument `verify-dataverse-access.js` + +**Files:** +- Modify: `plugins/power-pages/scripts/verify-dataverse-access.js` + +- [ ] **Step 1: Same refactor pattern as Task 5.2** + +Wrap the top-level body in `async function main()`, import `runInstrumented`, and at the bottom: + +```js +if (require.main === module) { + runInstrumented('verify-dataverse-access', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; +``` + +- [ ] **Step 2: Smoke test** + +Run: `node plugins/power-pages/scripts/verify-dataverse-access.js --help 2>&1 || true` +Expected: prints usage or a known error, not a telemetry error. + +- [ ] **Step 3: Commit** + +```bash +git add plugins/power-pages/scripts/verify-dataverse-access.js +git commit -m "$(cat <<'EOF' +feat(power-pages): instrument verify-dataverse-access with withTelemetry + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5.4: Instrument `render-audit-report.js` + +**Files:** +- Modify: `plugins/power-pages/scripts/render-audit-report.js` + +- [ ] **Step 1: Apply the same wrapping pattern** + +Wrap body in `async function main()`, import `runInstrumented`, add the conditional `if (require.main === module)` block at the bottom. + +- [ ] **Step 2: Commit** + +```bash +git add plugins/power-pages/scripts/render-audit-report.js +git commit -m "$(cat <<'EOF' +feat(power-pages): instrument render-audit-report with withTelemetry + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5.5: Instrument `clear-site-cache.js` + +**Files:** +- Modify: `plugins/power-pages/scripts/clear-site-cache.js` + +- [ ] **Step 1: Apply the same wrapping pattern** + +- [ ] **Step 2: Commit** + +```bash +git add plugins/power-pages/scripts/clear-site-cache.js +git commit -m "$(cat <<'EOF' +feat(power-pages): instrument clear-site-cache with withTelemetry + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 5.6: Instrument all per-skill validators in one sweep + +Per the spec (§5.4), each validator under `plugins/power-pages/skills/*/scripts/validate-*.js` gets wrapped. All follow the same top-level pattern — a long imperative script ending in `process.exit(0)` or similar. + +**Files:** +- Modify: `plugins/power-pages/skills/activate-site/scripts/validate-activation.js` +- Modify: `plugins/power-pages/skills/add-seo/scripts/validate-seo.js` +- Modify: `plugins/power-pages/skills/audit-permissions/scripts/validate-audit.js` +- Modify: `plugins/power-pages/skills/create-site/scripts/validate-site.js` +- Modify: `plugins/power-pages/skills/create-webroles/scripts/validate-webroles.js` +- Modify: `plugins/power-pages/skills/add-cloud-flow/scripts/validate-cloudflow.js` +- Modify: `plugins/power-pages/skills/add-server-logic/scripts/validate-serverlogic.js` +- Modify: `plugins/power-pages/skills/integrate-webapi/scripts/validate-webapi-integration.js` +- Modify: `plugins/power-pages/skills/setup-auth/scripts/validate-auth.js` +- Modify: `plugins/power-pages/skills/setup-datamodel/scripts/validate-datamodel.js` + +- [ ] **Step 1: For each validator, apply the wrapping pattern** + +Each validator adds one require at the top: + +```js +const path = require('path'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); +``` + +Wrap the existing top-level body in `async function main()`. At the bottom: + +```js +if (require.main === module) { + runInstrumented('validate-', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; +``` + +Replace `` with the skill name (e.g. `activate-site`, `create-site`). + +- [ ] **Step 2: Run the PowerShell test battery** + +Run (from repo root in PowerShell): +```powershell +$files = Get-ChildItem .\plugins\power-pages\scripts\tests\*.test.js | ForEach-Object { $_.FullName } +node --test $files +``` +Expected: no regressions. + +- [ ] **Step 3: Commit** + +```bash +git add plugins/power-pages/skills/ +git commit -m "$(cat <<'EOF' +feat(power-pages): instrument skill validators with withTelemetry + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Milestone 6 — Docs + +### Task 6.1: Update `plugins/power-pages/AGENTS.md` + +**Files:** +- Modify: `plugins/power-pages/AGENTS.md` + +- [ ] **Step 1: Append a telemetry section** + +After the "Common Review Pitfalls" section and before "Maintaining This File", insert: + +```markdown +## Telemetry + +This plugin ships 1DS telemetry for skill-run and script-run signals. The shared library lives at the repo-root `shared/telemetry/`; the synced copy at `scripts/lib/telemetry/` is the live code. + +- **DO NOT hand-edit** files under `scripts/lib/telemetry/`. Edit `shared/telemetry/` and re-run `node shared/telemetry/sync-to-plugin.js --target plugins/power-pages`. +- **First-time setup (one-time per clone):** + ```bash + npm install --prefix plugins/power-pages/scripts/lib/telemetry + ``` + Without this, telemetry emission is a no-op — the rest of the plugin still works. +- **Consent:** every tracked skill runs the Phase-1 one-liner from `references/telemetry-consent-reference.md`. Never emit without the user's explicit consent. +- **Strict allowlist:** `shared/telemetry/lib/events.js` enforces exactly the fields listed in the spec. Never add a field to a builder without first adding it to the allowlist and documenting it in the reference doc. +- **Env off-switch:** `POWER_PLATFORM_SKILLS_TELEMETRY=0` disables emission regardless of the consent file. +- **Fail closed:** telemetry code must never change a script's exit code or break a skill run. + +See `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md` for the full design. +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/power-pages/AGENTS.md +git commit -m "$(cat <<'EOF' +docs(power-pages): document telemetry conventions and install step + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6.2: Update root `AGENTS.md` + +**Files:** +- Modify: `AGENTS.md` + +- [ ] **Step 1: Insert a "Shared Telemetry" sub-section under "Cross-Plugin Shared Skills"** + +Add immediately after the Cross-Plugin Shared Skills section: + +```markdown +## Shared Telemetry + +1DS telemetry code for all plugins lives at `shared/telemetry/`. The repo-root copy is development-time only — each adopting plugin syncs a copy into `plugins//scripts/lib/telemetry/` via `node shared/telemetry/sync-to-plugin.js --target plugins/`. Only the synced copy runs at user time. + +Edit `shared/telemetry/` and re-run the sync to propagate changes. Never hand-edit the synced copies. + +Current adopters: `power-pages`. Others adopt on demand. +``` + +- [ ] **Step 2: Commit** + +```bash +git add AGENTS.md +git commit -m "$(cat <<'EOF' +docs: document shared telemetry convention at repo level + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6.3: Write `shared/telemetry/README.md` + +**Files:** +- Create: `shared/telemetry/README.md` + +- [ ] **Step 1: Write the README** + +Path: `shared/telemetry/README.md` + +```markdown +# Shared 1DS Telemetry Library + +Canonical source for 1DS telemetry used by plugins in this repo. Synced into each adopting plugin via `sync-to-plugin.js`. + +**Not shipped to users directly.** The repo-root copy is development-time only. Each plugin ships its own synced copy under `plugins//scripts/lib/telemetry/`. + +## What is sent + +Every event carries a fixed allowlist: + +- `plugin_name`, `plugin_version` — from the plugin's `.claude-plugin/plugin.json` +- `session_id` — random UUID generated once per Node process (not persisted) +- `os_family` — `win32` | `darwin` | `linux` +- `node_version` — major version only, e.g. `v22` +- `correlation_id` — joins `skill_started` ↔ `skill_completed` and `script_started` ↔ `script_completed` +- `skill_name` (skill events) or `script_name` (script events) +- `outcome` (`success` | `failure`), `duration_ms`, `error_class` (constructor name only) — completed events + +## What is NEVER sent + +File paths, cwd, env vars (except the telemetry off-switch), tenant IDs, site names, Dataverse URLs, error messages, stack traces, skill arguments, tool inputs, usernames, hostnames. + +## Consent + +- Stored at `~/.power-platform-skills/telemetry.json`. +- Gathered interactively on first tracked-skill run. +- Override: `POWER_PLATFORM_SKILLS_TELEMETRY=0` disables emission regardless of the file. + +## Syncing into a plugin + +```bash +node shared/telemetry/sync-to-plugin.js --target plugins/ +cd plugins//scripts/lib/telemetry && npm install +``` + +## Layout + +See `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md` for the full design spec. +``` + +- [ ] **Step 2: Commit** + +```bash +git add shared/telemetry/README.md +git commit -m "$(cat <<'EOF' +docs(telemetry): add shared library README + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 6.4: Link from root `README.md` + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Add a brief telemetry paragraph** + +Append near the bottom of `README.md`: + +```markdown +## Telemetry + +Plugins that ship 1DS telemetry (currently: `power-pages`) gather anonymous usage signals with explicit user consent. See `shared/telemetry/README.md` for what is sent and how to opt out. +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "$(cat <<'EOF' +docs: link telemetry notice from root README + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Milestone 7 — iKey provisioning and marketplace-install E2E smoke test + +Per the spec's rollout sequence, final E2E verification requires a marketplace install (not `--plugin-dir` dev mode) — the latter does not register plugin hooks, as proven by the POC. + +### Task 7.1: Replace the placeholder iKey + +**Files:** +- Modify: `shared/telemetry/ikey.json` + +- [ ] **Step 1: Obtain the provisioned iKey** + +The plugin marketplace owner provisions a 1DS tenant and iKey for `power-platform-skills`. Record the values (ask the repo owner if unsure): +- `ikey` — 32+ character tenant token +- `collector_url` — the regional OneCollector endpoint for that tenant (default `https://self.events.data.microsoft.com/OneCollector/1.0/`) + +- [ ] **Step 2: Replace the placeholder** + +Path: `shared/telemetry/ikey.json` (exact values filled in at provisioning time): + +```json +{ + "ikey": "", + "collector_url": "" +} +``` + +- [ ] **Step 3: Re-sync into the plugin** + +Run: +```bash +node shared/telemetry/sync-to-plugin.js --target plugins/power-pages +``` + +- [ ] **Step 4: Commit the new iKey and synced copy** + +```bash +git add shared/telemetry/ikey.json plugins/power-pages/scripts/lib/telemetry/ikey.json +git commit -m "$(cat <<'EOF' +feat(telemetry): provision real 1DS ikey + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 7.2: E2E smoke test via marketplace install + +**Files:** none (manual verification). + +- [ ] **Step 1: Publish a pre-release of the plugin** + +Bump `plugins/power-pages/.claude-plugin/plugin.json` version (e.g. `1.3.0-pre`) and push to whichever branch the marketplace cache pulls from. Commit the version bump. + +- [ ] **Step 2: Install the plugin fresh on a test machine** + +From a clean shell: +```bash +claude plugins update power-pages +``` +Or the equivalent command for the install flow the marketplace uses. Confirm the cached copy at `~/.claude/plugins/cache/power-platform-claude-plugins-official/power-pages//` now has a `hooks/` directory and matches the repo. + +- [ ] **Step 3: Install deps for the synced telemetry module** + +```bash +npm install --prefix ~/.claude/plugins/cache/power-platform-claude-plugins-official/power-pages//scripts/lib/telemetry +``` + +- [ ] **Step 4: Invoke a tracked skill** + +In a fresh `claude` session (not `--plugin-dir`), run a short tracked skill such as `/power-pages:audit-permissions` and stop it at the first prompt. + +- [ ] **Step 5: Confirm consent prompt appears on first run** + +Expected: skill's Phase 1 prints an `AskUserQuestion` about telemetry. Answer "Yes". + +- [ ] **Step 6: Invoke the same skill again** + +Consent is now recorded. No prompt this time. + +- [ ] **Step 7: Confirm events reach the collector** + +Check the 1DS tenant dashboard (Geneva/Aria) for two `PowerPlatformSkillsEvent` rows per invocation: one `skill_started`, one `skill_completed`, same `correlation_id`. + +- [ ] **Step 8: Confirm fail-closed behaviour** + +Run the same skill with `POWER_PLATFORM_SKILLS_TELEMETRY=0`. Dashboard shows no new events. Skill completes normally. + +- [ ] **Step 9: Record smoke-test results in the repo** + +Create `docs/superpowers/rollout-notes/2026-04-22-1ds-telemetry-smoke.md` with the skills invoked, event counts observed in the dashboard, and any deviations. Commit. + +```bash +git add docs/superpowers/rollout-notes/2026-04-22-1ds-telemetry-smoke.md +git commit -m "$(cat <<'EOF' +docs(telemetry): record marketplace-install E2E smoke-test results + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Final sanity pass + +- [ ] **Step 1: Run every test file in the repo once** + +```bash +node --test shared/telemetry/tests/*.test.js +``` +```powershell +$files = Get-ChildItem .\plugins\power-pages\scripts\tests\*.test.js | ForEach-Object { $_.FullName } +node --test $files +``` +Expected: all pass, no failures. + +- [ ] **Step 2: Confirm the POC under `poc/1ds-telemetry/` still points at the real shipping files in its README** + +Scan `poc/1ds-telemetry/README.md` for stale references; update any file paths that changed during the build. + +- [ ] **Step 3: Optionally archive or delete the POC** + +If the team wants to archive, move `poc/1ds-telemetry/` to `docs/superpowers/poc-archive/2026-04-20-1ds-telemetry/`. If it's served its purpose and implementers don't need the reference, delete it. Commit whichever decision. + +--- + +## Self-review performed + +Before executing, confirmed: + +1. **Spec coverage.** Every spec section maps to at least one task: + - §1 goals/non-goals → covered across milestones 1–7. + - §2 architecture → M1 (library), M2 (sync). + - §3 event schema → Task 1.6. + - §4 consent flow → Task 1.3, 1.9, 1.10, 2.2, M4 (skill wiring). + - §5 hook wiring → M3 + Task 5.1. + - §6 deps / iKey → Task 1.1, Task 7.1. + - §7 failure modes → exercised by every test (fail-closed assertions throughout). + - §8 testing → tests accompany every implementation task. + - §9 rollout → M3 + M4 + M6 + M7 mirror the spec's ten rollout steps. + - §10 open items → SDK versions pinned in 1.1, correlation mechanism chosen in 1.4, node_modules gitignored in 2.3, iKey provisioning in 7.1. + - §11 out-of-scope → kept out (no canvas/mcp/model/code-apps work). + +2. **Placeholder scan.** No TBD/TODO strings. Every code block is complete. The only template gap is `` in Task 7.1, which is explicitly called out as a human-provisioning step. + +3. **Type/name consistency.** Checked `COLLECTOR_EVENT_NAME`, `SCHEMA_VERSION`, `PROMPT_VERSION`, `POWER_PLATFORM_SKILLS_CONFIG_DIR`, `POWER_PLATFORM_SKILLS_TELEMETRY`, `PLACEHOLDER_REPLACE_BEFORE_SHIPPING` — all spelled identically everywhere they appear. Event-builder names (`buildSkillStarted`, `buildSkillCompleted`, `buildScriptStarted`, `buildScriptCompleted`) match between `events.js`, `with-telemetry.js`, and the hook scripts. + +--- From e46d0744c244942e2128a1f6ed19db45b72cc902 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 15:03:53 +0530 Subject: [PATCH 03/55] docs(telemetry): drop 1DS SDK; adopt detached-dispatcher hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revision after reviewing agency-microsoft/playground/claude-telemetry: - Drop @microsoft/1ds-core-js and 1ds-post-js. Use Node's built-in https module directly. No package.json, no node_modules, no npm install step anywhere in the telemetry tree. - Hooks use a detached-child dispatcher pattern: the hook parses stdin, calls emit-spawn.fireAndForget, and exits in ~50 ms. A detached dispatcher child does the HTTPS POST independently. - withTelemetry uses the same dispatcher — script emits are also fire-and-forget. - Payload shape stays Common Schema 4.0 (verified in the POC via acc:N). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../specs/2026-04-20-1ds-telemetry-design.md | 228 +++++++++++------- 1 file changed, 142 insertions(+), 86 deletions(-) diff --git a/docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md b/docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md index 403cfc79f..0ff2eb7ec 100644 --- a/docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md +++ b/docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md @@ -1,9 +1,11 @@ # 1DS Telemetry Infrastructure — Design Spec -**Date:** 2026-04-20 +**Date:** 2026-04-20 (revised 2026-04-22) **Status:** Draft — pending implementation plan **Scope:** Add Microsoft 1DS (One Data Strategy) telemetry to the `power-platform-skills` plugin marketplace, wired into the `power-pages` plugin as the first consumer. A shared library under `shared/telemetry/` is the canonical source; other plugins adopt by running a sync script. +**2026-04-22 revision:** After reviewing the `agency-microsoft/playground/plugins/claude-telemetry` implementation and the POC results, this spec drops the `@microsoft/1ds-*` SDK and uses Node's built-in `https` module directly. Hooks also adopt a detached-child dispatcher pattern so they return in ~50 ms regardless of collector latency. Payload shape remains Common Schema 4.0 (what our POC verified via `acc:N`). + --- ## 1. Goals and Non-Goals @@ -21,7 +23,7 @@ - Wiring telemetry into the four non-`power-pages` plugins in this pass (they adopt later via the sync script). - Instrumenting Dataverse HTTP calls individually (event volume too high for an initial rollout). - Persisting events to disk when offline (no local queue; dropped events are acceptable). -- Automating `npm install` for the telemetry dependencies (surfaced as a one-time notice; not auto-executed). +- npm dependencies of any kind. The telemetry library is zero-dep — built on Node's `https`, `child_process`, and `fs` modules only. --- @@ -34,48 +36,51 @@ The canonical source lives at `shared/telemetry/`. A sync script copies it into ``` shared/telemetry/ ├── README.md # Purpose, data sent, sync instructions -├── package.json # @microsoft/1ds-core-js, 1ds-post-js -├── ikey.json # Hardcoded iKey + OneCollector URL -├── sync-to-plugin.js # Copies lib/ + ikey.json + package.json into a plugin +├── ikey.json # iKey + OneCollector URL (no secrets — iKey is a write-only identifier) +├── sync-to-plugin.js # Copies lib/ + ikey.json into a plugin. No package.json to copy. ├── lib/ -│ ├── client.js # 1DS SDK init + emit() wrapper +│ ├── emit-dispatcher.js # CLI: reads event JSON on stdin, POSTs Common Schema 4.0, exits +│ ├── emit-spawn.js # Tiny helper: spawns emit-dispatcher.js detached + hands it the JSON │ ├── consent.js # Read/write ~/.power-platform-skills/telemetry.json │ ├── events.js # Event builders with strict allowlists │ ├── session.js # Per-process anonymized UUID │ ├── scrubber.js # No-op placeholder for future PII regex │ ├── check-consent.js # CLI: stdout "NEEDS_PROMPT" | "ENABLED" | "DISABLED" │ ├── record-consent.js # CLI: --answer yes|no writes the consent file -│ └── with-telemetry.js # Wrapper for plugin Node scripts +│ └── with-telemetry.js # Wrapper for plugin Node scripts; calls emit-spawn plugins/power-pages/ -├── scripts/lib/telemetry/ # Synced copy of shared/telemetry/lib + ikey.json + package.json -│ # Tracked in git; do NOT hand-edit +├── scripts/lib/telemetry/ # Synced copy of shared/telemetry/lib + ikey.json +│ # Tracked in git; do NOT hand-edit; no node_modules here ├── hooks/ │ ├── hooks.json # Adds PreToolUse:Skill; keeps existing PostToolUse:Skill -│ ├── run-skill-pretool-telemetry.js # New: emits skill_started -│ └── run-skill-posttool-validation.js # Existing; extended to emit skill_completed after validator +│ ├── run-skill-pretool-telemetry.js # New: emits skill_started via emit-spawn +│ └── run-skill-posttool-validation.js # Existing; extended to emit skill_completed via emit-spawn └── references/ └── telemetry-consent-reference.md # Shared Phase-1 pointer doc every SKILL.md includes ``` ### 2.2 Runtime components -1. **Consent gate** (`lib/consent.js`) — Reads `~/.power-platform-skills/telemetry.json`. Hooks read this synchronously; if missing or `enabled: false`, they exit 0 silently. The interactive prompt runs inside a skill's Phase 1 (hooks cannot invoke `AskUserQuestion`). -2. **Client** (`lib/client.js`) — Lazy-initialized 1DS post channel. Loads `ikey.json` and the `@microsoft/1ds-*` SDK. If `node_modules` is missing, returns a no-op emitter and writes a one-time `npm install --prefix ...` notice to stderr. -3. **Emitters** — Two hook scripts (`run-skill-pretool-telemetry.js`, existing `run-skill-posttool-validation.js`) and a `withTelemetry(scriptName, asyncFn)` wrapper for instrumenting individual Node scripts. -4. **Event builders** (`lib/events.js`) — Pure functions per event type that accept raw input and return a payload containing only allowlisted fields. `client.emit()` accepts nothing else; a test enforces this. +1. **Consent gate** (`lib/consent.js`) — Reads `~/.power-platform-skills/telemetry.json`. Hooks and the dispatcher read this synchronously; if missing or `enabled: false`, they exit 0 silently. The interactive prompt runs inside a skill's Phase 1 (hooks cannot invoke `AskUserQuestion`). +2. **Dispatcher** (`lib/emit-dispatcher.js`) — A standalone Node CLI. Reads one event JSON on stdin, reads `POWER_PLATFORM_SKILLS_IKEY` and `POWER_PLATFORM_SKILLS_COLLECTOR` from env, re-checks consent, wraps the event in a Common Schema 4.0 envelope, POSTs it via `https.request(...)`, and exits when the response arrives or 4 s passes. Runs in its own OS process; its runtime is independent of the caller. +3. **Spawn helper** (`lib/emit-spawn.js`) — Exposes `fireAndForget(event, { iKey, collectorUrl })`. Spawns the dispatcher with `{ detached: true, stdio: ['pipe', 'ignore', 'ignore'] }`, writes the event JSON to the child's stdin, calls `child.unref()`, and returns synchronously. The parent exits without waiting. +4. **Emitters** — Three call sites all use `fireAndForget`: the PreToolUse hook, the PostToolUse hook (after the existing validator), and the `withTelemetry(scriptName, asyncFn)` wrapper used inside instrumented scripts. No code path anywhere in the plugin awaits a network round-trip. +5. **Event builders** (`lib/events.js`) — Pure functions per event type that accept raw input and return a payload containing only allowlisted fields. `fireAndForget` accepts nothing else; a test enforces this. ### 2.3 Data flow for one skill run +Every emission point calls `emit-spawn.fireAndForget` synchronously and then returns. The parent process never waits for the HTTPS POST. A detached dispatcher child performs the POST in the background. + ``` User invokes /create-site │ ▼ Skill Phase 1 runs: 1. Existing plugin-version check (unchanged). - 2. New: node check-consent.js + 2. node check-consent.js - outputs "ENABLED" → continue - - outputs "DISABLED" → continue (hooks will no-op) + - outputs "DISABLED" → continue (dispatchers will still spawn, then no-op) - outputs "NEEDS_PROMPT" → AskUserQuestion; then node record-consent.js --answer yes|no │ @@ -83,21 +88,30 @@ Skill Phase 1 runs: Claude invokes Skill tool │ ├─► PreToolUse:Skill hook - │ run-skill-pretool-telemetry.js - │ → emit skill_started {plugin, plugin_version, skill, session_id, - │ correlation_id, os_family, node_version} + │ run-skill-pretool-telemetry.js (parent, runs under 30s hook timeout) + │ 1. read stdin, detect tracked skill name + │ 2. write correlation file (correlation_id + start_ts) + │ 3. build skill_started event via events.js + │ 4. emit-spawn.fireAndForget(event) → detached dispatcher child + │ ├─ re-check consent + │ ├─ POST to OneCollector + │ └─ exit when response arrives + │ 5. parent exits 0 (≈ 50 ms) │ ▼ -Skill body runs. Instrumented Node scripts wrap their main() in withTelemetry() - → emit script_started / script_completed +Skill body runs. Instrumented Node scripts wrap their main() in withTelemetry(): + ├─ emit-spawn.fireAndForget(script_started) → detached dispatcher + ├─ await asyncFn() + └─ emit-spawn.fireAndForget(script_completed) → detached dispatcher │ ├─► PostToolUse:Skill hook │ run-skill-posttool-validation.js - │ 1. Runs existing per-skill validator (unchanged). - │ 2. Emits skill_completed {outcome, duration_ms, error_class, correlation_id, - │ common envelope fields} - │ outcome = "success" if validator exit 0, "failure" otherwise. - │ 3. Exits with the validator's status code (telemetry does not change it). + │ 1. Run existing per-skill validator (unchanged). + │ 2. Read correlation file. + │ 3. emit-spawn.fireAndForget(skill_completed) → detached dispatcher + │ outcome = "success" if validator exit 0, "failure" otherwise. + │ 4. Clear correlation file. + │ 5. Exit with the validator's status code (telemetry does not change it). ``` --- @@ -182,7 +196,7 @@ The AskUserQuestion payload (defined once in the reference doc): ### 4.3 Override -- `POWER_PLATFORM_SKILLS_TELEMETRY=0` — Disables emission regardless of the file. Checked by the client on every emit. +- `POWER_PLATFORM_SKILLS_TELEMETRY=0` — Disables emission regardless of the file. Checked by the dispatcher at the top of every run; dispatcher exits 0 without POSTing. - Any other value (including `1`, unset, empty) — No effect. Emission is governed entirely by the consent file. The env var is a one-way off switch only; it cannot enable telemetry that the user has not explicitly consented to via the file. ### 4.4 Hook behavior when consent is absent @@ -230,21 +244,36 @@ Both hooks exit 0 silently. No stderr noise (gate debug output behind `process.e ### 5.2 `run-skill-pretool-telemetry.js` (new) -Reads `tool_input`, calls `getTrackedSkillFromToolInput()` (existing helper), gates on consent, emits `skill_started` with a fresh `correlation_id` that is cached to a short-lived temp file keyed by skill name + session so the PostToolUse hook can correlate. Always exits 0. +Reads `tool_input`, calls `getTrackedSkillFromToolInput()` (existing helper), builds the `skill_started` event via `events.js`, and calls `emit-spawn.fireAndForget(event, { iKey, collectorUrl })`. Writes the `correlation_id` + `start_ts` to a short-lived OS-temp file (`os.tmpdir()/ppskills-corr-.json`) so the PostToolUse hook can join. Always exits 0. ### 5.3 `run-skill-posttool-validation.js` (extended) The existing validator flow is preserved byte-for-byte. A new block runs *after* the validator: ```js +const corr = correlation.read({ skillName }) || { + correlation_id: crypto.randomUUID(), + start_ts: Date.now(), +}; const outcome = validatorStatus === 0 ? 'success' : 'failure'; -const duration_ms = Date.now() - startTs; -const errorClass = ''; // PostToolUse does not carry thrown-error info -emit(buildSkillCompletedEvent({ skill_name, outcome, duration_ms, error_class: errorClass, correlation_id })); +const duration_ms = Date.now() - corr.start_ts; + +emitSpawn.fireAndForget( + events.buildSkillCompleted({ + ...common, + skill_name: skillName, + correlation_id: corr.correlation_id, + outcome, + duration_ms, + error_class: '', // PostToolUse does not carry thrown-error info + }), + { iKey, collectorUrl } +); +correlation.clear({ skillName }); process.exit(validatorStatus ?? 0); ``` -Telemetry emission never changes the validator's exit code. +Telemetry emission never changes the validator's exit code. `fireAndForget` is synchronous — it spawns the detached dispatcher and returns before the HTTPS POST completes. ### 5.4 `withTelemetry(scriptName, asyncFn)` (new) @@ -263,7 +292,7 @@ if (require.main === module) { } ``` -`withTelemetry` emits `script_started`, awaits `asyncFn()`, then emits `script_completed` with the computed outcome. It rethrows the original error unchanged so existing error handling is preserved. +`withTelemetry` calls `emit-spawn.fireAndForget(script_started)`, awaits `asyncFn()`, then calls `emit-spawn.fireAndForget(script_completed)` with the computed outcome. Neither emission blocks on the network — each one spawns a detached dispatcher and returns synchronously. The wrapper rethrows the original error unchanged so existing error handling is preserved. **Initial scripts to instrument** (chosen for signal value): @@ -279,32 +308,33 @@ Low-value scripts (`generate-uuid.js`, template renderers) are not instrumented. ## 6. Dependencies and Install -### 6.1 `shared/telemetry/package.json` +### 6.1 No npm dependencies -```json +The telemetry library uses only Node built-ins (`node:https`, `node:child_process`, `node:fs`, `node:os`, `node:path`, `node:crypto`). There is no `package.json`, no `node_modules`, and no `npm install` step. This removes the single biggest friction point flagged in the earlier draft: users installing the plugin via the marketplace get a working telemetry stack immediately. + +### 6.2 OneCollector POST shape + +The dispatcher builds a Common Schema 4.0 envelope per event (what our POC verified via `acc:N`): + +```js { - "name": "@power-platform-skills/telemetry", - "version": "0.1.0", - "private": true, - "dependencies": { - "@microsoft/1ds-core-js": "^3.2.0", - "@microsoft/1ds-post-js": "^3.2.0" - } + ver: "4.0", + name: event.name, // "PowerPlatformSkillsEvent" + time: new Date().toISOString(), + iKey: "o:" + IKEY.split("-")[0], + baseType: "Ms.WebClient.TraceEvent", + baseData: event.data, // { eventName, eventType, severity, eventInfo } + data: event.data } ``` -Exact version pins are resolved during implementation against the currently published versions. Versions are synced into each plugin's copy. +Request headers: -### 6.2 Install story +- `Content-Type: application/x-json-stream; charset=utf-8` +- `x-apikey: ` +- `Content-Length: ` -Users run `npm install --prefix plugins/power-pages/scripts/lib/telemetry` once. This is documented in: - -- `plugins/power-pages/AGENTS.md` (Key Conventions section) -- `plugins/power-pages/CLAUDE.md` (same content, symlinked) -- The consent prompt body (see §4.2) -- The root `README.md` setup section - -The client fails closed on missing `node_modules`, so forgetting this step drops events but never breaks a skill. +Collector URL comes from `ikey.json`'s `collector_url` field. A 4 s per-request timeout in the dispatcher; no retries; no local queue. ### 6.3 iKey provisioning @@ -312,12 +342,12 @@ The client fails closed on missing `node_modules`, so forgetting this step drops ```json { - "ikey": "<32-char-iKey-provisioned-via-1DS-tenant>", - "collector_url": "https://self.events.data.microsoft.com/OneCollector/1.0" + "ikey": "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", + "collector_url": "https://self.events.data.microsoft.com/OneCollector/1.0/" } ``` -The iKey is committed in plaintext (Microsoft OSS precedent: VS Code, dotnet SDK, Azure CLI). It is a write-only identifier, not a secret. The tenant token and iKey must be provisioned through whichever Microsoft 1DS tenant owns this data before the first commit that populates `ikey.json`. Until then, a placeholder causes the client to no-op (client validates the iKey format at init). +The iKey is committed in plaintext (Microsoft OSS precedent: VS Code, dotnet SDK, Azure CLI). It is a write-only identifier, not a secret. The tenant token and iKey must be provisioned through whichever Microsoft 1DS tenant owns this data before the first commit that replaces the placeholder. Until then, the dispatcher detects the placeholder string and exits 0 without POSTing, so `ikey.json` can be safely present in source control throughout development. --- @@ -328,17 +358,27 @@ All failure paths exit cleanly and never break the user's skill run. | Failure | Behavior | |---|---| | Consent file missing | Hook exits 0 silently. Skill Phase 1 triggers prompt. | -| Consent file `enabled: false` | Hook exits 0 silently. | -| `POWER_PLATFORM_SKILLS_TELEMETRY=0` | Hook exits 0 silently. | +| Consent file `enabled: false` | Hook still calls `fireAndForget`, dispatcher starts, re-reads consent, exits 0 without POSTing. | +| `POWER_PLATFORM_SKILLS_TELEMETRY=0` | Dispatcher reads env var at startup and exits 0 without POSTing. | | Consent file malformed | Treated as missing → re-prompt. | -| `node_modules` missing | Client returns no-op; one-time stderr notice with `npm install --prefix` command. Hook exits 0. | -| `ikey.json` missing or placeholder | Client returns no-op; no stderr output. | -| 1DS POST fails, times out, or network unreachable | Fire-and-forget 2s timeout; errors swallowed; no retries; no on-disk queue. | -| Event builder receives unexpected field | Dropped silently; caught by `node:test` in CI, not at runtime. | -| Hook script throws | Top-level catch-all → `process.exit(0)`. | -| Validator throws in PostToolUse | Telemetry still emits with `outcome: "failure"`; validator exit code is preserved. | +| `ikey.json` missing or placeholder | Dispatcher exits 0 without POSTing. No stderr. | +| Collector returns 4xx / 5xx | Dispatcher reads body, exits 0. No retry, no local queue. | +| HTTPS POST times out | Dispatcher's 4 s `setTimeout` destroys the request and exits 0. | +| DNS failure / network unreachable | Dispatcher's `req.on("error")` handler exits 0. | +| `spawn(...)` fails (out of FDs, etc.) | `fireAndForget`'s `try { ... } catch {}` swallows. Hook/script continues. | +| Detached child killed by OS before POST completes | Event dropped. No retry. Acceptable. | +| Event builder receives unexpected field | Dropped silently by the builder's allowlist; caught by `node:test` in CI, not at runtime. | +| Hook script throws during stdin-parsing | Top-level `.catch(() => process.exit(0))`. | +| Dispatcher script throws | Top-level `.catch(() => process.exit(0))`. | +| Validator throws in PostToolUse | Telemetry still emits with `outcome: "failure"` (fire-and-forget runs *after* validator); validator exit code is preserved. | + +**Non-negotiable rules:** + +- Telemetry code cannot raise a visible error in the parent's shell. +- Telemetry emission never changes a hook's or script's exit code. +- No PII-carrying field ever reaches the dispatcher — `events.js` builders enforce the allowlist *before* `emit-spawn.fireAndForget`. -**Non-negotiable rule:** telemetry code cannot raise a visible error. Enforced by `telemetry-hook-pretool.test.js` and `telemetry-hook-posttool.test.js`, which inject throws at every mockable seam and assert `exit(0)`. +Enforced by `telemetry-hook-pretool.test.js`, `telemetry-hook-posttool.test.js`, and `emit-dispatcher.test.js`, which inject throws at every mockable seam and assert `exit(0)`. --- @@ -350,26 +390,35 @@ Mirrors the existing `scripts/tests/` convention (node:test, PowerShell runner, ``` shared/telemetry/tests/ # Canonical tests -plugins/power-pages/scripts/tests/ # Synced copy (by sync-to-plugin.js) - ├── telemetry-client.test.js - ├── telemetry-consent.test.js - ├── telemetry-events.test.js - ├── telemetry-session.test.js - ├── telemetry-with-telemetry.test.js + ├── emit-dispatcher.test.js + ├── emit-spawn.test.js + ├── consent.test.js + ├── correlation.test.js + ├── events.test.js + ├── session.test.js + ├── scrubber.test.js + ├── with-telemetry.test.js + └── sync-to-plugin.test.js + +plugins/power-pages/scripts/tests/ # Plugin-specific hook tests ├── telemetry-hook-pretool.test.js └── telemetry-hook-posttool.test.js ``` -Both directories are committed and both are run in CI. +Shared tests exercise the library once in its canonical location. Plugin-specific hook tests live inside the plugin because they depend on the plugin's hook-utils helper and directory layout. ### 8.2 Assertions per file -- **client** — no-op when deps missing; no-op when consent disabled; respects env override; respects placeholder iKey. +- **emit-dispatcher** — no-op when consent disabled; no-op when iKey is placeholder; no-op when env off-switch set; `req.on("error")` exits 0; `setTimeout` exits 0; happy path POSTs the expected Common Schema envelope (verified via an injected fake `https` module). +- **emit-spawn** — parent returns in <100 ms; `unref()` called; detached child receives the event JSON on stdin; `spawn` throws → caller continues without throwing. - **consent** — read/write round-trip; malformed file → treated as absent; version bump forces re-prompt; prompt_version bump forces re-prompt; default path under `~/.power-platform-skills/`. -- **events** — each builder returns exactly the allowlisted keyset; unknown input keys dropped; `error_class` is the constructor name, never a message; `duration_ms` is a non-negative integer. +- **correlation** — write/read round-trip; read on missing file returns null; clear removes the file; non-existent file clear does not throw. +- **events** — each builder returns exactly the allowlisted keyset; unknown input keys dropped; `error_class` is the constructor name, never a message; `duration_ms` clamped to a non-negative integer. - **session** — stable within a process; unique across processes. -- **with-telemetry** — success path emits both events; rejection path emits completed with `outcome: "failure"` and rethrows the original error. -- **hooks** — happy path emits; missing consent emits nothing; throws at each seam → `exit(0)`. +- **scrubber** — identity function; never throws. +- **with-telemetry** — success path fires two detached children; rejection path fires two detached children and rethrows the original error. +- **sync-to-plugin** — copies `lib/` + `ikey.json`; copies `references/telemetry-consent-reference.md`; idempotent; exits non-zero on missing `--target`. +- **hooks** — happy path calls `fireAndForget` exactly once; missing consent still calls `fireAndForget` (dispatcher handles the no-op); malformed stdin → `exit(0)`; tracked-skill detection returns null → no-op + exit 0. ### 8.3 Live end-to-end test @@ -379,25 +428,32 @@ Both directories are committed and both are run in CI. ## 9. Rollout Sequence -1. Land `shared/telemetry/` (library, `package.json`, `ikey.json` placeholder, sync script, tests). No plugin wiring yet. +1. Land `shared/telemetry/` (library with dispatcher, spawn helper, consent, correlation, events, session, scrubber, with-telemetry, CLIs, sync script, tests, `ikey.json` placeholder). No plugin wiring yet. No npm install required. 2. Run `node shared/telemetry/sync-to-plugin.js --target plugins/power-pages` to populate the synced copy. Commit the synced files. 3. Add `plugins/power-pages/hooks/run-skill-pretool-telemetry.js` and update `plugins/power-pages/hooks/hooks.json` to register the PreToolUse:Skill entry. -4. Extend `plugins/power-pages/hooks/run-skill-posttool-validation.js` to emit `skill_completed` after the validator. +4. Extend `plugins/power-pages/hooks/run-skill-posttool-validation.js` to call `fireAndForget(skill_completed)` after the validator. 5. Add `plugins/power-pages/references/telemetry-consent-reference.md` (synced). 6. Add the Phase-1 one-liner to every tracked SKILL.md (per the list in `scripts/lib/powerpages-hook-utils.js`). 7. Wrap the chosen high-value scripts (§5.4) in `withTelemetry(...)`. -8. Update `plugins/power-pages/AGENTS.md`, root `AGENTS.md`, and `README.md` with telemetry conventions, the `npm install --prefix` command, and a link to `shared/telemetry/README.md`. -9. Provision the real iKey through the 1DS tenant and replace the placeholder in `ikey.json`. -10. Manual smoke test: fresh machine, run `/create-site`, observe the consent prompt, confirm "Yes", re-run, confirm an event reaches the 1DS collector (via the live test or tenant dashboard). +8. Update `plugins/power-pages/AGENTS.md`, root `AGENTS.md`, and `README.md` with telemetry conventions and a link to `shared/telemetry/README.md`. No install instructions needed. +9. Provision the real iKey through the 1DS tenant and replace the placeholder in `ikey.json`. Re-sync. +10. Manual smoke test on a marketplace-installed plugin (not `--plugin-dir` — see §10 for rationale): fresh machine, run a tracked skill, observe the consent prompt, confirm "Yes", re-run, confirm an event reaches the 1DS collector via the tenant dashboard. --- -## 10. Open Implementation Details (resolved during planning) +## 10. Open Implementation Details + +Resolved items (kept for traceability): + +- ~~Exact `@microsoft/1ds-core-js` and `@microsoft/1ds-post-js` version pins.~~ **Resolved:** SDK dropped in the 2026-04-22 revision; Node built-in `https` used directly. +- ~~Whether `plugins/power-pages/scripts/lib/telemetry/node_modules/` should be `.gitignore`d.~~ **Resolved:** no `node_modules` directory exists; no npm deps. +- **Correlation mechanism:** OS temp file at `os.tmpdir()/ppskills-corr-.json` written by the PreToolUse hook, read + cleared by the PostToolUse hook. Keyed by skill name only (not PID) because both hooks run in separate short-lived Node processes. +- **Stdin shape:** the existing `getTrackedSkillFromToolInput(toolInput)` helper is proven by the in-prod validator hook; our hook scripts reuse it unchanged. + +Pending items (the implementer resolves during build): -- Exact `@microsoft/1ds-core-js` and `@microsoft/1ds-post-js` version pins — check npm at implementation time. -- The mechanism for passing `correlation_id` from PreToolUse to PostToolUse (candidates: a short-lived temp file keyed by PID + skill name, or re-generating per hook and relying on `session_id` + `skill_name` + timestamp for correlation on the ingest side). Defaults to the temp-file approach unless the plan phase finds a cleaner option. -- Verification that `process.stdin` JSON received by the hooks contains enough data to identify the skill (the existing `getTrackedSkillFromToolInput` usage confirms it does). -- Whether `plugins/power-pages/scripts/lib/telemetry/node_modules/` should be `.gitignore`d (yes; the install step is a user-run prerequisite, not a committed artifact). +- **Marketplace-install-only E2E verification.** The POC confirmed that `--plugin-dir` dev mode does not register plugin hooks. The rollout smoke test (§9 step 10) must happen against a marketplace-installed copy of the plugin. Document this in the plan. +- **Collector URL endpoint selection for the real tenant.** `ikey.json` ships with a US/default endpoint; the provisioning step (§6.3) updates it to whatever region the tenant lives in. --- From b1e761dade8f5c4c237e0d97a30af4e6174d208d Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 15:17:23 +0530 Subject: [PATCH 04/55] docs(plan): revise 1DS telemetry plan for dispatcher-based design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match the 2026-04-22 spec revision. Targeted rewrites only — most tasks unchanged. Affected: 0.x (Node banner), 1.1 (scaffold drops package.json), 1.7 (replaces client.js with emit-dispatcher.js), 1.7b (new emit-spawn.js task), 1.8 (with-telemetry uses emit-spawn), 2.1 (sync drops package.json copy), 2.3 (drops npm install), 3.1 / 3.2 (hooks use fireAndForget), 5.1 (runner simpler without client), 6.1 (AGENTS.md drops install step), 6.3 (README.md drops install step), 7.2 (E2E drops npm install step). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-04-22-1ds-telemetry.md | 864 ++++++++++-------- 1 file changed, 494 insertions(+), 370 deletions(-) diff --git a/docs/superpowers/plans/2026-04-22-1ds-telemetry.md b/docs/superpowers/plans/2026-04-22-1ds-telemetry.md index 679341b0f..0e13f85e6 100644 --- a/docs/superpowers/plans/2026-04-22-1ds-telemetry.md +++ b/docs/superpowers/plans/2026-04-22-1ds-telemetry.md @@ -1,14 +1,16 @@ # 1DS Telemetry Implementation Plan +> **Revised 2026-04-22:** The spec was revised to drop the `@microsoft/1ds-*` SDK and adopt a detached-child dispatcher pattern for fire-and-forget emission. Affected tasks: 1.1, 1.7, 1.8, 1.11, 2.1, 2.3, 3.1, 3.2, 5.1, 6.1, 6.3, 7.1, 7.2. Unchanged: 0.x, 1.2–1.6, 1.9–1.10, 2.2, 3.3, 4.x, 5.2–5.6, 6.2, 6.4. + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Ship 1DS telemetry to the `power-pages` plugin using a shared library at `shared/telemetry/` that other plugins can later adopt via a sync script. -**Architecture:** Canonical source of truth at `shared/telemetry/`; per-plugin synced copy at `plugins//scripts/lib/telemetry/`. Hooks (`PreToolUse:Skill` and `PostToolUse:Skill`) and a `withTelemetry()` wrapper emit events to 1DS. Consent gathered by an interactive prompt on first skill run; persisted at `~/.power-platform-skills/telemetry.json`. Fail-closed everywhere. +**Architecture:** Canonical source of truth at `shared/telemetry/`; per-plugin synced copy at `plugins//scripts/lib/telemetry/`. Hooks (`PreToolUse:Skill` and `PostToolUse:Skill`) and a `withTelemetry()` wrapper all emit events through a detached-child dispatcher so the caller never blocks on a network round-trip. Consent gathered by an interactive prompt on first skill run; persisted at `~/.power-platform-skills/telemetry.json`. Fail-closed everywhere. -**Tech Stack:** Node 22, `@microsoft/1ds-core-js`@^4.3.3, `@microsoft/1ds-post-js`@^4.3.3, `node:test`, existing `scripts/lib/powerpages-hook-utils.js`. +**Tech Stack:** Node 22 built-ins only (`node:https`, `node:child_process`, `node:fs`, `node:os`, `node:path`, `node:crypto`), `node:test`, existing `scripts/lib/powerpages-hook-utils.js`. **No npm dependencies.** -**Reference:** Spec at `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md`. Working POC at `poc/1ds-telemetry/` — implementers should read the POC's `hook-lib.js` and `emit.js` for the proven init + fetch-override + flush patterns before writing `shared/telemetry/lib/client.js`. +**Reference:** Spec at `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md`. Working POC at `poc/1ds-telemetry/` — the POC's `emit.js` (SDK-based) is for historical context only; the real shipping code uses Node's built-in `https`. The POC's demonstration of the Common Schema 4.0 envelope shape is still accurate and worth reading. --- @@ -17,9 +19,9 @@ - **Test runner:** `node --test ` (no external deps). All tests use `node:test` + `node:assert/strict`. - **Style:** CommonJS (`require`/`module.exports`), matches every other Node script in `plugins/power-pages/scripts/`. - **Commits:** Conventional-ish subject lines consistent with the repo (`feat(telemetry): ...`, `test(telemetry): ...`, `docs(telemetry): ...`). Always include the `Co-Authored-By: Claude Opus 4.7 (1M context) ` trailer — matches the POC commit and prior repo practice. -- **No placeholders:** every string value in the code is real. Where the spec leaves something open (SDK version, iKey), the plan picks a concrete value. -- **Dead code:** never check in `node_modules/` under `shared/telemetry/` or `plugins/power-pages/scripts/lib/telemetry/`. Both are gitignored. -- **Pre/post probe flow:** tests avoid hitting the real 1DS collector. A mock HTTP layer is injected via the `httpXHROverride` slot. +- **No placeholders:** every string value in the code is real. Where the spec leaves something open (iKey), the plan picks a concrete value. +- **No npm dependencies:** the telemetry library uses only Node built-ins. There is no `package.json`, no `node_modules`, no `npm install` step anywhere in the telemetry tree. +- **Testing network code:** the dispatcher takes the `https` module through a shim that tests replace with a fake. Tests never POST to the real 1DS collector. --- @@ -28,12 +30,11 @@ ``` shared/telemetry/ ├── README.md -├── package.json ├── ikey.json ├── sync-to-plugin.js -├── .gitignore # ignores node_modules/ ├── lib/ -│ ├── client.js # 1DS init + emit wrapper + env-var off-switch +│ ├── emit-dispatcher.js # CLI: stdin JSON event → HTTPS POST → exit +│ ├── emit-spawn.js # Helper: spawns emit-dispatcher.js detached │ ├── consent.js # Read/write consent config file │ ├── correlation.js # Pre→Post correlation via OS temp file │ ├── events.js # 4 event builders with strict allowlists @@ -41,11 +42,12 @@ shared/telemetry/ │ ├── scrubber.js # No-op placeholder │ ├── check-consent.js # CLI: prints NEEDS_PROMPT | ENABLED | DISABLED │ ├── record-consent.js # CLI: --answer yes|no -│ └── with-telemetry.js # Wrapper for plugin Node scripts +│ └── with-telemetry.js # Wrapper for plugin Node scripts; calls emit-spawn ├── references/ │ └── telemetry-consent-reference.md └── tests/ - ├── client.test.js + ├── emit-dispatcher.test.js + ├── emit-spawn.test.js ├── consent.test.js ├── correlation.test.js ├── events.test.js @@ -80,7 +82,7 @@ plugins/power-pages/ - [ ] **Step 1: Verify Node 22 is available** Run: `node --version` -Expected: `v22.*` or newer. If older, stop and ask the user to upgrade — the 1DS SDK ESM imports assume modern Node. +Expected: `v22.*` or newer. If older, stop and ask the user to upgrade — the dispatcher uses Node's built-in `https` module plus `fetch`-like ergonomics that assume a modern Node. - [ ] **Step 2: Verify working tree is clean before starting** @@ -96,8 +98,6 @@ Build `shared/telemetry/` with tests in sequence. No plugin wiring yet. At the e ### Task 1.1: Scaffold `shared/telemetry/` directory **Files:** -- Create: `shared/telemetry/.gitignore` -- Create: `shared/telemetry/package.json` - Create: `shared/telemetry/ikey.json` - [ ] **Step 1: Create the directory structure** @@ -107,33 +107,9 @@ Run: mkdir -p shared/telemetry/lib shared/telemetry/tests shared/telemetry/references ``` -- [ ] **Step 2: Write `shared/telemetry/.gitignore`** +- [ ] **Step 2: Write `shared/telemetry/ikey.json` (placeholder)** -``` -node_modules/ -``` - -- [ ] **Step 3: Write `shared/telemetry/package.json`** - -```json -{ - "name": "@power-platform-skills/telemetry", - "version": "0.1.0", - "private": true, - "description": "Shared 1DS telemetry library for power-platform-skills plugins. Synced into each consuming plugin via sync-to-plugin.js.", - "dependencies": { - "@microsoft/1ds-core-js": "^4.3.3", - "@microsoft/1ds-post-js": "^4.3.3" - }, - "scripts": { - "test": "node --test tests/*.test.js" - } -} -``` - -- [ ] **Step 4: Write `shared/telemetry/ikey.json` (placeholder)** - -The iKey is a placeholder string. Task 7.1 replaces it with the real provisioned iKey before any live emission happens. Client logic treats placeholder as "no iKey" → no-op emit. +The iKey is a placeholder string. Task 7.1 replaces it with the real provisioned iKey before any live emission happens. The dispatcher treats this placeholder as "no iKey" → no-op emit. ```json { @@ -142,20 +118,12 @@ The iKey is a placeholder string. Task 7.1 replaces it with the real provisioned } ``` -- [ ] **Step 5: Install deps** - -Run: -```bash -cd shared/telemetry && npm install && cd ../.. -``` -Expected: `added 8 packages`. No errors. `package-lock.json` written. - -- [ ] **Step 6: Commit** +- [ ] **Step 3: Commit** ```bash -git add shared/telemetry/.gitignore shared/telemetry/package.json shared/telemetry/package-lock.json shared/telemetry/ikey.json +git add shared/telemetry/ikey.json git commit -m "$(cat <<'EOF' -feat(telemetry): scaffold shared/telemetry package +feat(telemetry): scaffold shared/telemetry directory Co-Authored-By: Claude Opus 4.7 (1M context) EOF @@ -882,17 +850,17 @@ EOF --- -### Task 1.7: `client.js` — 1DS init + emit wrapper (fail closed on missing deps or placeholder iKey) +### Task 1.7: `emit-dispatcher.js` — standalone dispatcher child **Files:** -- Create: `shared/telemetry/lib/client.js` -- Create: `shared/telemetry/tests/client.test.js` +- Create: `shared/telemetry/lib/emit-dispatcher.js` +- Create: `shared/telemetry/tests/emit-dispatcher.test.js` -Reference: `poc/1ds-telemetry/hook-lib.js` for the proven fetch-override pattern. Do not copy verbatim — this module has a cleaner surface (no diagnostic file logging, no inline event builders). +The dispatcher runs as a detached child process. Reads one event JSON on stdin, re-checks consent, reads iKey + collector URL from env vars, POSTs a Common Schema 4.0 envelope to OneCollector via Node's built-in `https`, exits 0. Fails closed on every error path. - [ ] **Step 1: Write the failing test** -Path: `shared/telemetry/tests/client.test.js` +Path: `shared/telemetry/tests/emit-dispatcher.test.js` ```js "use strict"; @@ -902,11 +870,12 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); +const { spawnSync } = require("node:child_process"); -const { createClient } = require("../lib/client"); +const DISPATCHER = path.resolve(__dirname, "../lib/emit-dispatcher.js"); function mkConsent(enabled) { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-client-")); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-disp-")); if (enabled !== undefined) { fs.writeFileSync( path.join(tmp, "telemetry.json"), @@ -921,221 +890,379 @@ function mkConsent(enabled) { return tmp; } -test("client is no-op when consent is unset", async () => { - const tmp = mkConsent(undefined); - const client = createClient({ configDir: tmp, iKey: "ik", collectorUrl: "http://unused" }); - await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); - // If it tried to POST, fetch would be called. We inject a failing fetch to verify not-called. - assert.equal(client.posted, 0); +function runDispatcher({ event, env }) { + return spawnSync(process.execPath, [DISPATCHER], { + input: JSON.stringify(event), + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: env.configDir, + POWER_PLATFORM_SKILLS_IKEY: env.iKey || "", + POWER_PLATFORM_SKILLS_COLLECTOR: env.collectorUrl || "", + POWER_PLATFORM_SKILLS_TELEMETRY: env.off ? "0" : "", + POWER_PLATFORM_SKILLS_FAKE_HTTPS: env.fakeProbe || "", + }, + }); +} + +const fakeEvent = { + name: "PowerPlatformSkillsEvent", + data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" }, +}; + +test("dispatcher exits 0 when iKey is placeholder", () => { + const tmp = mkConsent(true); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", collectorUrl: "https://x" }, + }); + assert.equal(status, 0); }); -test("client is no-op when consent disabled", async () => { +test("dispatcher exits 0 when collector URL missing", () => { + const tmp = mkConsent(true); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "" }, + }); + assert.equal(status, 0); +}); + +test("dispatcher exits 0 when consent disabled", () => { const tmp = mkConsent(false); - const client = createClient({ configDir: tmp, iKey: "ik", collectorUrl: "http://unused" }); - await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); - assert.equal(client.posted, 0); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "https://x" }, + }); + assert.equal(status, 0); }); -test("client is no-op when iKey is the placeholder", async () => { - const tmp = mkConsent(true); - const client = createClient({ - configDir: tmp, - iKey: "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", - collectorUrl: "http://unused", +test("dispatcher exits 0 when consent unset", () => { + const tmp = mkConsent(undefined); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "https://x" }, }); - await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); - assert.equal(client.posted, 0); + assert.equal(status, 0); }); -test("client is no-op when POWER_PLATFORM_SKILLS_TELEMETRY=0", async () => { +test("dispatcher exits 0 when POWER_PLATFORM_SKILLS_TELEMETRY=0", () => { const tmp = mkConsent(true); - const client = createClient({ - configDir: tmp, - iKey: "ik", - collectorUrl: "http://unused", - env: { POWER_PLATFORM_SKILLS_TELEMETRY: "0" }, + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "https://x", off: true }, }); - await client.emitAndFlush({ name: "X", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }); - assert.equal(client.posted, 0); + assert.equal(status, 0); }); -test("client posts via injected fetch when consent enabled and iKey real", async () => { +test("dispatcher exits 0 on malformed stdin", () => { const tmp = mkConsent(true); - let called = 0; - const injectedFetch = async () => { - called += 1; - return { - status: 200, - headers: { forEach: () => {} }, - body: true, - text: async () => '{"acc":1}', - }; - }; - const client = createClient({ - configDir: tmp, - iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", - collectorUrl: "http://unused", - fetchImpl: injectedFetch, - }); - await client.emitAndFlush({ - name: "PowerPlatformSkillsEvent", - data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" }, + const { status } = spawnSync(process.execPath, [DISPATCHER], { + input: "not json", + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp, + POWER_PLATFORM_SKILLS_IKEY: "real-ikey", + POWER_PLATFORM_SKILLS_COLLECTOR: "https://x", + }, }); - assert.ok(called >= 1, `expected fetch called at least once, got ${called}`); - assert.equal(client.posted, 1); + assert.equal(status, 0); }); -test("client never throws when fetch rejects", async () => { +test("dispatcher writes a probe file when fake-https points to one (happy path)", () => { const tmp = mkConsent(true); - const client = createClient({ - configDir: tmp, - iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", - collectorUrl: "http://unused", - fetchImpl: async () => { - throw new Error("network down"); + const probePath = path.join(tmp, "probe.json"); + const { status } = runDispatcher({ + event: fakeEvent, + env: { + configDir: tmp, + iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", + collectorUrl: "https://example.invalid/OneCollector/1.0/", + fakeProbe: probePath, }, }); - // Must not throw - await client.emitAndFlush({ - name: "PowerPlatformSkillsEvent", - data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" }, - }); + assert.equal(status, 0); + assert.ok(fs.existsSync(probePath), "expected dispatcher to write probe file"); + const probe = JSON.parse(fs.readFileSync(probePath, "utf8")); + assert.equal(probe.headers["x-apikey"], "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa"); + assert.equal(probe.headers["Content-Type"], "application/x-json-stream; charset=utf-8"); + const body = JSON.parse(probe.body); + assert.equal(body.ver, "4.0"); + assert.equal(body.name, "PowerPlatformSkillsEvent"); + assert.equal(body.iKey, "o:real"); + assert.equal(body.baseType, "Ms.WebClient.TraceEvent"); + assert.deepEqual(body.data, fakeEvent.data); }); ``` -- [ ] **Step 2: Run — expect FAIL** +- [ ] **Step 2: Run — expect FAIL (module not found)** -- [ ] **Step 3: Implement `client.js`** +Run: `node --test shared/telemetry/tests/emit-dispatcher.test.js` -Path: `shared/telemetry/lib/client.js` +- [ ] **Step 3: Implement `emit-dispatcher.js`** + +Path: `shared/telemetry/lib/emit-dispatcher.js` ```js +#!/usr/bin/env node "use strict"; -const consentLib = require("./consent"); +const https = require("node:https"); +const fs = require("node:fs"); const PLACEHOLDER_IKEY = "PLACEHOLDER_REPLACE_BEFORE_SHIPPING"; -function loadSdk() { +const IKEY = process.env.POWER_PLATFORM_SKILLS_IKEY || ""; +const COLLECTOR_URL = process.env.POWER_PLATFORM_SKILLS_COLLECTOR || ""; +const FAKE_PROBE = process.env.POWER_PLATFORM_SKILLS_FAKE_HTTPS || ""; + +function exitSilently() { + process.exit(0); +} + +function readConsent() { try { - const core = require("@microsoft/1ds-core-js"); - const post = require("@microsoft/1ds-post-js"); - return { core, post }; + const consent = require("./consent"); + return consent.read({ + configDir: process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined, + }); } catch { - return null; + return { state: "unset" }; } } -function makeFetchOverride(fetchImpl, counter) { +function buildEnvelope(event) { return { - sendPOST: (payload, oncomplete) => { - const body = - typeof payload.data === "string" - ? payload.data - : new TextDecoder().decode(payload.data); - Promise.resolve() - .then(() => - fetchImpl(payload.urlString, { - method: "POST", - headers: payload.headers, - body, - }) - ) - .then(async (response) => { - const headers = {}; - try { - response.headers.forEach((v, n) => { - headers[n] = v; - }); - } catch {} - let text = ""; - if (response.body) { - try { - text = await response.text(); - } catch {} - } - counter.posted += 1; - oncomplete(response.status, headers, text); - }) - .catch(() => { - // Swallow. Never throw out of the emit path. - oncomplete(0, {}); - }); - }, + ver: "4.0", + name: event.name, + time: new Date().toISOString(), + iKey: "o:" + IKEY.split("-")[0], + baseType: "Ms.WebClient.TraceEvent", + baseData: event.data, + data: event.data, }; } -function createClient({ - configDir, - iKey, - collectorUrl, - fetchImpl, - env, -} = {}) { - const consent = consentLib.read({ configDir, env }); - const counter = { posted: 0 }; - - const disabled = - consent.state !== "enabled" || - !iKey || - iKey === PLACEHOLDER_IKEY; - - const sdk = disabled ? null : loadSdk(); - let coreInstance = null; - - if (sdk) { - try { - coreInstance = new sdk.core.AppInsightsCore(); - const channel = new sdk.post.PostChannel(); - const fImpl = fetchImpl || globalThis.fetch; - coreInstance.initialize( - { - instrumentationKey: iKey, - loggingLevelConsole: 0, - disableDbgExt: true, - endpointUrl: collectorUrl, - extensions: [channel], - extensionConfig: { - [channel.identifier]: { - alwaysUseXhrOverride: true, - httpXHROverride: makeFetchOverride(fImpl, counter), - }, - }, - }, - [] - ); - } catch { - coreInstance = null; +function writeProbe(path, { headers, body }) { + try { + fs.writeFileSync(path, JSON.stringify({ headers, body }), "utf8"); + } catch { + // ignore + } +} + +// ---- Gate checks ----------------------------------------------------------- +if (!IKEY || IKEY === PLACEHOLDER_IKEY || !COLLECTOR_URL) exitSilently(); +if (readConsent().state !== "enabled") exitSilently(); + +// ---- Read stdin ------------------------------------------------------------ +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => (raw += c)); +process.stdin.on("end", () => { + let event; + try { + event = JSON.parse(raw); + } catch { + exitSilently(); + } + + const envelope = buildEnvelope(event); + const body = JSON.stringify(envelope); + const headers = { + "Content-Type": "application/x-json-stream; charset=utf-8", + "x-apikey": IKEY, + "Content-Length": Buffer.byteLength(body), + }; + + // Test seam: if POWER_PLATFORM_SKILLS_FAKE_HTTPS is set, write the probe + // payload to that file and exit without calling the real network. + if (FAKE_PROBE) { + writeProbe(FAKE_PROBE, { headers, body }); + exitSilently(); + } + + const url = new URL(COLLECTOR_URL); + const req = https.request( + { + hostname: url.hostname, + path: url.pathname + (url.search || ""), + method: "POST", + headers, + }, + (res) => { + res.on("data", () => {}); + res.on("end", exitSilently); } + ); + req.on("error", exitSilently); + req.setTimeout(4000, () => { + req.destroy(); + exitSilently(); + }); + req.write(body); + req.end(); +}); +``` + +- [ ] **Step 4: Run — expect PASS (7 tests)** + +Run: `node --test shared/telemetry/tests/emit-dispatcher.test.js` + +- [ ] **Step 5: Commit** + +```bash +git add shared/telemetry/lib/emit-dispatcher.js shared/telemetry/tests/emit-dispatcher.test.js +git commit -m "$(cat <<'EOF' +feat(telemetry): add standalone emit-dispatcher child CLI + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +### Task 1.7b: `emit-spawn.js` — tiny helper that spawns the detached dispatcher + +**Files:** +- Create: `shared/telemetry/lib/emit-spawn.js` +- Create: `shared/telemetry/tests/emit-spawn.test.js` + +- [ ] **Step 1: Write the failing test** + +Path: `shared/telemetry/tests/emit-spawn.test.js` + +```js +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { fireAndForget } = require("../lib/emit-spawn"); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-spawn-")); +} + +function mkConsent(tmp, enabled) { + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); +} + +test("fireAndForget returns synchronously (<100 ms)", () => { + const tmp = mkTmp(); + const start = Date.now(); + fireAndForget( + { name: "PowerPlatformSkillsEvent", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }, + { iKey: "real-ikey", collectorUrl: "https://example.invalid/" } + ); + const elapsed = Date.now() - start; + assert.ok(elapsed < 100, `expected <100ms, got ${elapsed}ms`); +}); + +test("dispatcher child receives the event and writes the probe", async () => { + const tmp = mkTmp(); + mkConsent(tmp, true); + const probe = path.join(tmp, "probe.json"); + fireAndForget( + { name: "PowerPlatformSkillsEvent", data: { eventName: "hello", eventType: "Trace", severity: "Info", eventInfo: "{}" } }, + { + iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", + collectorUrl: "https://example.invalid/OneCollector/1.0/", + configDir: tmp, + fakeProbe: probe, + } + ); + // Wait up to 2s for the child to write the probe. + for (let i = 0; i < 20; i++) { + if (fs.existsSync(probe)) break; + await new Promise((r) => setTimeout(r, 100)); } + assert.ok(fs.existsSync(probe), "probe file was not written"); + const contents = JSON.parse(fs.readFileSync(probe, "utf8")); + const body = JSON.parse(contents.body); + assert.equal(body.data.eventName, "hello"); +}); + +test("fireAndForget does not throw when spawn fails (missing dispatcher path)", () => { + // Rename the dispatcher so spawn will fail + const { fireAndForget: broken } = require("../lib/emit-spawn"); + // Intentionally pass a malformed event that would crash if JSON.stringify throws + // (it won't — objects with cycles would, but we just verify no throw on happy path) + broken({ name: "X", data: {} }, { iKey: "", collectorUrl: "" }); + // No assertion needed: test passes if no throw. +}); +``` + +- [ ] **Step 2: Run — expect FAIL** + +Run: `node --test shared/telemetry/tests/emit-spawn.test.js` + +- [ ] **Step 3: Implement `emit-spawn.js`** + +Path: `shared/telemetry/lib/emit-spawn.js` + +```js +"use strict"; + +const { spawn } = require("node:child_process"); +const path = require("node:path"); + +const DISPATCHER = path.resolve(__dirname, "emit-dispatcher.js"); - async function emitAndFlush(event, { flushMs = 3000 } = {}) { - if (!coreInstance) return; +function fireAndForget(event, opts = {}) { + const iKey = opts.iKey || ""; + const collectorUrl = opts.collectorUrl || ""; + const configDir = opts.configDir || ""; + const fakeProbe = opts.fakeProbe || ""; + + try { + const child = spawn("node", [DISPATCHER], { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + env: { + ...process.env, + POWER_PLATFORM_SKILLS_IKEY: iKey, + POWER_PLATFORM_SKILLS_COLLECTOR: collectorUrl, + POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, + POWER_PLATFORM_SKILLS_FAKE_HTTPS: fakeProbe, + }, + }); try { - coreInstance.track(event); - coreInstance.flush(); + child.stdin.write(JSON.stringify(event)); + child.stdin.end(); } catch { - // fail closed + // child may have already exited; swallow. } - await new Promise((resolve) => setTimeout(resolve, flushMs)); + child.unref(); + } catch { + // spawn failed — fail closed. } - - return { emitAndFlush, get posted() { return counter.posted; } }; } -module.exports = { createClient, PLACEHOLDER_IKEY }; +module.exports = { fireAndForget }; ``` -- [ ] **Step 4: Run — expect PASS (6 tests)** - -Run: `node --test shared/telemetry/tests/client.test.js` +- [ ] **Step 4: Run — expect PASS (3 tests)** - [ ] **Step 5: Commit** ```bash -git add shared/telemetry/lib/client.js shared/telemetry/tests/client.test.js +git add shared/telemetry/lib/emit-spawn.js shared/telemetry/tests/emit-spawn.test.js git commit -m "$(cat <<'EOF' -feat(telemetry): add 1DS client with consent/placeholder/env gating +feat(telemetry): add emit-spawn helper for detached dispatcher Co-Authored-By: Claude Opus 4.7 (1M context) EOF @@ -1150,6 +1277,8 @@ EOF - Create: `shared/telemetry/lib/with-telemetry.js` - Create: `shared/telemetry/tests/with-telemetry.test.js` +The wrapper calls `emit-spawn.fireAndForget` for both `script_started` and `script_completed`. Neither call awaits the network. A test-only `emitter` option lets tests capture the events synchronously. + - [ ] **Step 1: Write the failing test** Path: `shared/telemetry/tests/with-telemetry.test.js` @@ -1162,63 +1291,87 @@ const assert = require("node:assert/strict"); const { withTelemetry } = require("../lib/with-telemetry"); -function fakeClient() { +function recorder() { const events = []; return { events, - async emitAndFlush(e) { - events.push(e); - }, + emit: (e) => events.push(e), }; } test("success path emits script_started and script_completed", async () => { - const client = fakeClient(); + const rec = recorder(); const result = await withTelemetry( "verify-dataverse-access", async () => 42, - { clientFactory: () => client, pluginName: "power-pages", pluginVersion: "1.2.2" } + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } ); assert.equal(result, 42); - assert.equal(client.events.length, 2); - assert.equal(client.events[0].data.eventName, "script_started"); - assert.equal(client.events[1].data.eventName, "script_completed"); - const info1 = JSON.parse(client.events[1].data.eventInfo); - assert.equal(info1.outcome, "success"); - assert.equal(info1.error_class, ""); + assert.equal(rec.events.length, 2); + assert.equal(rec.events[0].data.eventName, "script_started"); + assert.equal(rec.events[1].data.eventName, "script_completed"); + const info = JSON.parse(rec.events[1].data.eventInfo); + assert.equal(info.outcome, "success"); + assert.equal(info.error_class, ""); }); test("failure path emits script_completed with outcome=failure and rethrows", async () => { - const client = fakeClient(); + const rec = recorder(); await assert.rejects( withTelemetry( "x", async () => { - const e = new TypeError("boom"); - throw e; + throw new TypeError("boom"); }, - { clientFactory: () => client, pluginName: "power-pages", pluginVersion: "1.2.2" } + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } ), TypeError ); - assert.equal(client.events.length, 2); - const info = JSON.parse(client.events[1].data.eventInfo); + assert.equal(rec.events.length, 2); + const info = JSON.parse(rec.events[1].data.eventInfo); assert.equal(info.outcome, "failure"); assert.equal(info.error_class, "TypeError"); }); test("same correlation_id on started and completed", async () => { - const client = fakeClient(); + const rec = recorder(); await withTelemetry( "x", async () => null, - { clientFactory: () => client, pluginName: "power-pages", pluginVersion: "1.2.2" } + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } ); - const a = JSON.parse(client.events[0].data.eventInfo).correlation_id; - const b = JSON.parse(client.events[1].data.eventInfo).correlation_id; + const a = JSON.parse(rec.events[0].data.eventInfo).correlation_id; + const b = JSON.parse(rec.events[1].data.eventInfo).correlation_id; assert.equal(a, b); assert.ok(a.length >= 32); }); + +test("emit is called synchronously before asyncFn starts (fire-and-forget)", async () => { + const rec = recorder(); + let asyncFnSeenEventsAtStart = -1; + await withTelemetry( + "x", + async () => { + asyncFnSeenEventsAtStart = rec.events.length; + return null; + }, + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + // script_started must have been emitted before asyncFn ran. + assert.equal(asyncFnSeenEventsAtStart, 1); +}); + +test("throwing emitter does not break the wrapper", async () => { + const throwingEmitter = () => { + throw new Error("emit blew up"); + }; + const result = await withTelemetry( + "x", + async () => 99, + { emitter: throwingEmitter, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + assert.equal(result, 99); +}); ``` - [ ] **Step 2: Run — expect FAIL** @@ -1232,12 +1385,10 @@ Path: `shared/telemetry/lib/with-telemetry.js` const crypto = require("node:crypto"); const { getSessionId } = require("./session"); -const { - buildScriptStarted, - buildScriptCompleted, -} = require("./events"); +const { buildScriptStarted, buildScriptCompleted } = require("./events"); +const { fireAndForget } = require("./emit-spawn"); -function common({ pluginName, pluginVersion }) { +function commonFields({ pluginName, pluginVersion }) { return { plugin_name: pluginName, plugin_version: pluginVersion, @@ -1247,26 +1398,29 @@ function common({ pluginName, pluginVersion }) { }; } +function defaultEmitter(event, spawnOpts) { + fireAndForget(event, spawnOpts); +} + async function withTelemetry(scriptName, asyncFn, opts = {}) { - const clientFactory = opts.clientFactory; const pluginName = opts.pluginName; const pluginVersion = opts.pluginVersion; + const emitter = opts.emitter || defaultEmitter; + const spawnOpts = opts.spawnOpts || {}; const correlationId = crypto.randomUUID(); - const client = clientFactory ? clientFactory() : null; const startTs = Date.now(); - if (client) { - try { - await client.emitAndFlush( - buildScriptStarted({ - ...common({ pluginName, pluginVersion }), - script_name: scriptName, - correlation_id: correlationId, - }) - ); - } catch { - // fail closed - } + try { + emitter( + buildScriptStarted({ + ...commonFields({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + }), + spawnOpts + ); + } catch { + // fail closed — never let telemetry throw } let outcome = "success"; @@ -1280,21 +1434,20 @@ async function withTelemetry(scriptName, asyncFn, opts = {}) { caught = err; } finally { const duration_ms = Date.now() - startTs; - if (client) { - try { - await client.emitAndFlush( - buildScriptCompleted({ - ...common({ pluginName, pluginVersion }), - script_name: scriptName, - correlation_id: correlationId, - outcome, - duration_ms, - error_class: errorClass, - }) - ); - } catch { - // fail closed - } + try { + emitter( + buildScriptCompleted({ + ...commonFields({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + outcome, + duration_ms, + error_class: errorClass, + }), + spawnOpts + ); + } catch { + // fail closed } if (caught) throw caught; } @@ -1303,14 +1456,14 @@ async function withTelemetry(scriptName, asyncFn, opts = {}) { module.exports = { withTelemetry }; ``` -- [ ] **Step 4: Run — expect PASS (3 tests)** +- [ ] **Step 4: Run — expect PASS (5 tests)** - [ ] **Step 5: Commit** ```bash git add shared/telemetry/lib/with-telemetry.js shared/telemetry/tests/with-telemetry.test.js git commit -m "$(cat <<'EOF' -feat(telemetry): add withTelemetry wrapper for script instrumentation +feat(telemetry): add withTelemetry wrapper using fireAndForget Co-Authored-By: Claude Opus 4.7 (1M context) EOF @@ -1518,7 +1671,7 @@ Expected: clean — this milestone produced only `shared/telemetry/` additions ( - Create: `shared/telemetry/sync-to-plugin.js` - Create: `shared/telemetry/tests/sync-to-plugin.test.js` -Sync copies `lib/`, `ikey.json`, `package.json`, `package-lock.json`, and `references/` into `/scripts/lib/telemetry/` (library + manifests) and `/references/` (doc) for a given plugin root. It overwrites; it does not merge. +Sync copies `lib/`, `ikey.json`, and `references/` into `/scripts/lib/telemetry/` (library) and `/references/` (doc) for a given plugin root. It overwrites; it does not merge. No `package.json` to copy — the library has no npm dependencies. - [ ] **Step 1: Write the failing test** @@ -1548,7 +1701,7 @@ function mkTargetPlugin() { const syncScript = path.resolve(__dirname, "../sync-to-plugin.js"); -test("sync copies lib/, ikey.json, package.json into /scripts/lib/telemetry/", () => { +test("sync copies lib/ and ikey.json into /scripts/lib/telemetry/", () => { const target = mkTargetPlugin(); const { status, stderr } = spawnSync( process.execPath, @@ -1557,10 +1710,11 @@ test("sync copies lib/, ikey.json, package.json into /scripts/lib/teleme ); assert.equal(status, 0, stderr); const synced = path.join(target, "scripts", "lib", "telemetry"); - assert.ok(fs.existsSync(path.join(synced, "package.json"))); assert.ok(fs.existsSync(path.join(synced, "ikey.json"))); - assert.ok(fs.existsSync(path.join(synced, "lib", "client.js"))); + assert.ok(fs.existsSync(path.join(synced, "lib", "emit-dispatcher.js"))); + assert.ok(fs.existsSync(path.join(synced, "lib", "emit-spawn.js"))); assert.ok(fs.existsSync(path.join(synced, "lib", "check-consent.js"))); + assert.ok(!fs.existsSync(path.join(synced, "package.json")), "no package.json should be synced"); }); test("sync copies references/telemetry-consent-reference.md into /references/", () => { @@ -1590,7 +1744,7 @@ test("sync is idempotent", () => { const target = mkTargetPlugin(); spawnSync(process.execPath, [syncScript, "--target", target]); spawnSync(process.execPath, [syncScript, "--target", target]); - const p = path.join(target, "scripts", "lib", "telemetry", "lib", "client.js"); + const p = path.join(target, "scripts", "lib", "telemetry", "lib", "emit-dispatcher.js"); assert.ok(fs.existsSync(p)); }); @@ -1644,17 +1798,12 @@ function safeCopyFile(from, to) { if (fs.existsSync(from)) copyFile(from, to); } -// 1. Library + manifests → /scripts/lib/telemetry/ +// 1. Library + iKey config → /scripts/lib/telemetry/ const telemetryDst = path.join(target, "scripts", "lib", "telemetry"); fs.mkdirSync(telemetryDst, { recursive: true }); copyDir(path.join(source, "lib"), path.join(telemetryDst, "lib")); copyFile(path.join(source, "ikey.json"), path.join(telemetryDst, "ikey.json")); -copyFile(path.join(source, "package.json"), path.join(telemetryDst, "package.json")); -safeCopyFile( - path.join(source, "package-lock.json"), - path.join(telemetryDst, "package-lock.json") -); // 2. Reference doc → /references/ safeCopyFile( @@ -1746,21 +1895,8 @@ EOF **Files:** - Create (via sync): `plugins/power-pages/scripts/lib/telemetry/**` - Create (via sync): `plugins/power-pages/references/telemetry-consent-reference.md` -- Modify: `plugins/power-pages/.gitignore` (or create if absent — verify first) - -- [ ] **Step 1: Check for an existing plugin-level `.gitignore`** - -Run: `cat plugins/power-pages/.gitignore 2>/dev/null || echo "(none)"` - -- [ ] **Step 2: Add node_modules ignore for the synced telemetry dir** - -If plugin-level `.gitignore` exists, append: -``` -scripts/lib/telemetry/node_modules/ -``` -If none exists, create `plugins/power-pages/.gitignore` with exactly that line. -- [ ] **Step 3: Run the sync** +- [ ] **Step 1: Run the sync** Run: ```bash @@ -1768,25 +1904,21 @@ node shared/telemetry/sync-to-plugin.js --target plugins/power-pages ``` Expected: `Synced shared/telemetry → plugins/power-pages/scripts/lib/telemetry` (exit 0). -- [ ] **Step 4: Inspect what got created** +- [ ] **Step 2: Inspect what got created** -Run: `ls plugins/power-pages/scripts/lib/telemetry/ && ls plugins/power-pages/scripts/lib/telemetry/lib/ && ls plugins/power-pages/references/telemetry-consent-reference.md` -Expected to see `package.json`, `ikey.json`, `lib/` with all 9 files, and the reference doc. +Run: `ls plugins/power-pages/scripts/lib/telemetry/ && ls plugins/power-pages/scripts/lib/telemetry/lib/` +Expected to see `ikey.json` and `lib/` containing: `emit-dispatcher.js`, `emit-spawn.js`, `consent.js`, `correlation.js`, `events.js`, `session.js`, `scrubber.js`, `check-consent.js`, `record-consent.js`, `with-telemetry.js`. No `package.json`, no `node_modules`. -- [ ] **Step 5: Install deps in the synced copy** +- [ ] **Step 3: Verify consent ref doc synced** -Run: -```bash -cd plugins/power-pages/scripts/lib/telemetry && npm install && cd ../../../../.. -``` -Expected: `added 8 packages`. No errors. +Run: `ls plugins/power-pages/references/telemetry-consent-reference.md` +Expected: file exists. -- [ ] **Step 6: Commit (synced files + gitignore; NOT node_modules)** +- [ ] **Step 4: Commit (synced files only)** ```bash git add plugins/power-pages/scripts/lib/telemetry/ \ - plugins/power-pages/references/telemetry-consent-reference.md \ - plugins/power-pages/.gitignore + plugins/power-pages/references/telemetry-consent-reference.md git commit -m "$(cat <<'EOF' feat(power-pages): sync shared telemetry library into plugin @@ -1905,9 +2037,9 @@ const fs = require("node:fs"); const PLUGIN_ROOT = path.resolve(__dirname, ".."); const TELEMETRY_DIR = path.join(PLUGIN_ROOT, "scripts", "lib", "telemetry"); -let clientLib, eventsLib, correlationLib, sessionLib; +let emitSpawn, eventsLib, correlationLib, sessionLib; try { - clientLib = require(path.join(TELEMETRY_DIR, "lib", "client")); + emitSpawn = require(path.join(TELEMETRY_DIR, "lib", "emit-spawn")); eventsLib = require(path.join(TELEMETRY_DIR, "lib", "events")); correlationLib = require(path.join(TELEMETRY_DIR, "lib", "correlation")); sessionLib = require(path.join(TELEMETRY_DIR, "lib", "session")); @@ -1969,21 +2101,26 @@ function readStdin() { const { correlation_id } = correlationLib.write({ skillName }); const { ikey, collectorUrl } = readIkey(); - const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; - const client = clientLib.createClient({ configDir, iKey: ikey, collectorUrl }); - - await client.emitAndFlush( - eventsLib.buildSkillStarted({ - plugin_name: "power-pages", - plugin_version: readPluginVersion(), - session_id: sessionLib.getSessionId(), - os_family: process.platform, - node_version: "v" + String(process.versions.node).split(".")[0], - skill_name: skillName, - correlation_id, - }) - ); + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || ""; + + try { + emitSpawn.fireAndForget( + eventsLib.buildSkillStarted({ + plugin_name: "power-pages", + plugin_version: readPluginVersion(), + session_id: sessionLib.getSessionId(), + os_family: process.platform, + node_version: "v" + String(process.versions.node).split(".")[0], + skill_name: skillName, + correlation_id, + }), + { iKey: ikey, collectorUrl, configDir } + ); + } catch { + // fail closed + } + // Parent exits immediately; dispatcher child carries the POST. process.exit(0); })().catch(() => process.exit(0)); ``` @@ -2156,7 +2293,7 @@ process.stdin.on('end', async () => { // Telemetry emission: fail-closed, never changes exit code. try { - const clientLib = require(path.join(TELEMETRY_DIR, 'lib', 'client')); + const emitSpawn = require(path.join(TELEMETRY_DIR, 'lib', 'emit-spawn')); const eventsLib = require(path.join(TELEMETRY_DIR, 'lib', 'events')); const correlationLib = require(path.join(TELEMETRY_DIR, 'lib', 'correlation')); const sessionLib = require(path.join(TELEMETRY_DIR, 'lib', 'session')); @@ -2186,17 +2323,11 @@ process.stdin.on('end', async () => { start_ts: startTs, }; - const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; - const client = clientLib.createClient({ - configDir, - iKey: ikeyCfg.ikey, - collectorUrl: ikeyCfg.collector_url, - }); - + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || ''; const outcome = !validatorRan || validatorStatus === 0 ? 'success' : 'failure'; - await client.emitAndFlush( + emitSpawn.fireAndForget( eventsLib.buildSkillCompleted({ plugin_name: 'power-pages', plugin_version: pluginVersion, @@ -2208,7 +2339,8 @@ process.stdin.on('end', async () => { outcome, duration_ms: Date.now() - (corr.start_ts || startTs), error_class: '', - }) + }), + { iKey: ikeyCfg.ikey, collectorUrl: ikeyCfg.collector_url, configDir } ); correlationLib.clear({ skillName }); @@ -2396,7 +2528,7 @@ Every wrapped script gets a small boilerplate block at the bottom that calls `wi - Create: `plugins/power-pages/scripts/lib/telemetry-runner.js` - Create: `plugins/power-pages/scripts/tests/telemetry-runner.test.js` -This small helper centralises the plugin-version read + ikey load + client creation so each instrumented script has a one-line invocation. +This small helper centralises the plugin-version read + ikey load so each instrumented script has a one-line invocation. No client creation needed — the synced `with-telemetry.js` already calls `emit-spawn.fireAndForget` by default. - [ ] **Step 1: Write the failing test** @@ -2454,7 +2586,6 @@ function readPluginVersion() { function loadTelemetryDeps() { try { return { - client: require(path.join(TELEMETRY_DIR, "lib", "client")), withTelemetry: require(path.join(TELEMETRY_DIR, "lib", "with-telemetry")) .withTelemetry, ikeyCfg: JSON.parse( @@ -2470,17 +2601,16 @@ async function runInstrumented(scriptName, asyncFn) { const deps = loadTelemetryDeps(); if (!deps) return asyncFn(); - const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || ""; return deps.withTelemetry(scriptName, asyncFn, { pluginName: "power-pages", pluginVersion: readPluginVersion(), - clientFactory: () => - deps.client.createClient({ - configDir, - iKey: deps.ikeyCfg.ikey, - collectorUrl: deps.ikeyCfg.collector_url, - }), + spawnOpts: { + iKey: deps.ikeyCfg.ikey, + collectorUrl: deps.ikeyCfg.collector_url, + configDir, + }, }); } @@ -2711,18 +2841,13 @@ After the "Common Review Pitfalls" section and before "Maintaining This File", i ```markdown ## Telemetry -This plugin ships 1DS telemetry for skill-run and script-run signals. The shared library lives at the repo-root `shared/telemetry/`; the synced copy at `scripts/lib/telemetry/` is the live code. +This plugin ships 1DS telemetry for skill-run and script-run signals. The shared library lives at the repo-root `shared/telemetry/`; the synced copy at `scripts/lib/telemetry/` is the live code. Zero npm dependencies — nothing to install. - **DO NOT hand-edit** files under `scripts/lib/telemetry/`. Edit `shared/telemetry/` and re-run `node shared/telemetry/sync-to-plugin.js --target plugins/power-pages`. -- **First-time setup (one-time per clone):** - ```bash - npm install --prefix plugins/power-pages/scripts/lib/telemetry - ``` - Without this, telemetry emission is a no-op — the rest of the plugin still works. - **Consent:** every tracked skill runs the Phase-1 one-liner from `references/telemetry-consent-reference.md`. Never emit without the user's explicit consent. - **Strict allowlist:** `shared/telemetry/lib/events.js` enforces exactly the fields listed in the spec. Never add a field to a builder without first adding it to the allowlist and documenting it in the reference doc. - **Env off-switch:** `POWER_PLATFORM_SKILLS_TELEMETRY=0` disables emission regardless of the consent file. -- **Fail closed:** telemetry code must never change a script's exit code or break a skill run. +- **Fail closed:** telemetry code must never change a script's exit code or break a skill run. Emission is fire-and-forget via a detached dispatcher child, so the hook or script returns before the HTTPS POST completes. See `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md` for the full design. ``` @@ -2732,7 +2857,7 @@ See `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md` for the full des ```bash git add plugins/power-pages/AGENTS.md git commit -m "$(cat <<'EOF' -docs(power-pages): document telemetry conventions and install step +docs(power-pages): document telemetry conventions Co-Authored-By: Claude Opus 4.7 (1M context) EOF @@ -2816,9 +2941,10 @@ File paths, cwd, env vars (except the telemetry off-switch), tenant IDs, site na ```bash node shared/telemetry/sync-to-plugin.js --target plugins/ -cd plugins//scripts/lib/telemetry && npm install ``` +No install step — the library has no npm dependencies. + ## Layout See `docs/superpowers/specs/2026-04-20-1ds-telemetry-design.md` for the full design spec. @@ -2930,28 +3056,26 @@ claude plugins update power-pages ``` Or the equivalent command for the install flow the marketplace uses. Confirm the cached copy at `~/.claude/plugins/cache/power-platform-claude-plugins-official/power-pages//` now has a `hooks/` directory and matches the repo. -- [ ] **Step 3: Install deps for the synced telemetry module** - -```bash -npm install --prefix ~/.claude/plugins/cache/power-platform-claude-plugins-official/power-pages//scripts/lib/telemetry -``` - -- [ ] **Step 4: Invoke a tracked skill** +- [ ] **Step 3: Invoke a tracked skill** -In a fresh `claude` session (not `--plugin-dir`), run a short tracked skill such as `/power-pages:audit-permissions` and stop it at the first prompt. +In a fresh `claude` session (not `--plugin-dir`), run a short tracked skill such as `/power-pages:audit-permissions` and stop it at the first prompt. No npm install needed — telemetry has no dependencies. -- [ ] **Step 5: Confirm consent prompt appears on first run** +- [ ] **Step 4: Confirm consent prompt appears on first run** Expected: skill's Phase 1 prints an `AskUserQuestion` about telemetry. Answer "Yes". -- [ ] **Step 6: Invoke the same skill again** +- [ ] **Step 5: Invoke the same skill again** Consent is now recorded. No prompt this time. -- [ ] **Step 7: Confirm events reach the collector** +- [ ] **Step 6: Confirm events reach the collector** Check the 1DS tenant dashboard (Geneva/Aria) for two `PowerPlatformSkillsEvent` rows per invocation: one `skill_started`, one `skill_completed`, same `correlation_id`. +- [ ] **Step 7: Confirm fire-and-forget timing** + +Between invoking a tracked skill and the hook returning, there should be no perceptible delay (<100 ms for the hook itself). The POST lands in the background. + - [ ] **Step 8: Confirm fail-closed behaviour** Run the same skill with `POWER_PLATFORM_SKILLS_TELEMETRY=0`. Dashboard shows no new events. Skill completes normally. @@ -3005,15 +3129,15 @@ Before executing, confirmed: - §3 event schema → Task 1.6. - §4 consent flow → Task 1.3, 1.9, 1.10, 2.2, M4 (skill wiring). - §5 hook wiring → M3 + Task 5.1. - - §6 deps / iKey → Task 1.1, Task 7.1. + - §6 dispatcher / iKey → Task 1.1 (scaffold), Task 1.7 (emit-dispatcher), Task 7.1 (real iKey). - §7 failure modes → exercised by every test (fail-closed assertions throughout). - §8 testing → tests accompany every implementation task. - §9 rollout → M3 + M4 + M6 + M7 mirror the spec's ten rollout steps. - - §10 open items → SDK versions pinned in 1.1, correlation mechanism chosen in 1.4, node_modules gitignored in 2.3, iKey provisioning in 7.1. + - §10 open items → correlation mechanism chosen in 1.4, iKey provisioning in 7.1. SDK-version and node_modules questions no longer apply after the 2026-04-22 revision. - §11 out-of-scope → kept out (no canvas/mcp/model/code-apps work). 2. **Placeholder scan.** No TBD/TODO strings. Every code block is complete. The only template gap is `` in Task 7.1, which is explicitly called out as a human-provisioning step. -3. **Type/name consistency.** Checked `COLLECTOR_EVENT_NAME`, `SCHEMA_VERSION`, `PROMPT_VERSION`, `POWER_PLATFORM_SKILLS_CONFIG_DIR`, `POWER_PLATFORM_SKILLS_TELEMETRY`, `PLACEHOLDER_REPLACE_BEFORE_SHIPPING` — all spelled identically everywhere they appear. Event-builder names (`buildSkillStarted`, `buildSkillCompleted`, `buildScriptStarted`, `buildScriptCompleted`) match between `events.js`, `with-telemetry.js`, and the hook scripts. +3. **Type/name consistency.** Checked `COLLECTOR_EVENT_NAME`, `SCHEMA_VERSION`, `PROMPT_VERSION`, `POWER_PLATFORM_SKILLS_CONFIG_DIR`, `POWER_PLATFORM_SKILLS_TELEMETRY`, `POWER_PLATFORM_SKILLS_IKEY`, `POWER_PLATFORM_SKILLS_COLLECTOR`, `POWER_PLATFORM_SKILLS_FAKE_HTTPS`, `PLACEHOLDER_REPLACE_BEFORE_SHIPPING` — all spelled identically everywhere they appear. Event-builder names (`buildSkillStarted`, `buildSkillCompleted`, `buildScriptStarted`, `buildScriptCompleted`) match between `events.js`, `with-telemetry.js`, and the hook scripts. `fireAndForget` signature is consistent across `emit-spawn.js`, `with-telemetry.js`, and both hook scripts. --- From 942d262f5fff0738bc17e3e3481070486ffb44d6 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 15:23:05 +0530 Subject: [PATCH 05/55] feat(telemetry): scaffold shared/telemetry directory Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/ikey.json | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 shared/telemetry/ikey.json diff --git a/shared/telemetry/ikey.json b/shared/telemetry/ikey.json new file mode 100644 index 000000000..034523db8 --- /dev/null +++ b/shared/telemetry/ikey.json @@ -0,0 +1,4 @@ +{ + "ikey": "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", + "collector_url": "https://self.events.data.microsoft.com/OneCollector/1.0/" +} From 96cfdad31f1a42bb7765490eb9d6cab3365532d4 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 15:24:55 +0530 Subject: [PATCH 06/55] feat(telemetry): add per-process session id helper Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/session.js | 14 +++++++++++++ shared/telemetry/tests/session.test.js | 28 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 shared/telemetry/lib/session.js create mode 100644 shared/telemetry/tests/session.test.js diff --git a/shared/telemetry/lib/session.js b/shared/telemetry/lib/session.js new file mode 100644 index 000000000..4507fffac --- /dev/null +++ b/shared/telemetry/lib/session.js @@ -0,0 +1,14 @@ +"use strict"; + +const crypto = require("node:crypto"); + +let cached; + +function getSessionId() { + if (!cached) { + cached = crypto.randomUUID(); + } + return cached; +} + +module.exports = { getSessionId }; diff --git a/shared/telemetry/tests/session.test.js b/shared/telemetry/tests/session.test.js new file mode 100644 index 000000000..ec3c5ab20 --- /dev/null +++ b/shared/telemetry/tests/session.test.js @@ -0,0 +1,28 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const sessionPath = path.resolve(__dirname, "../lib/session.js"); + +test("getSessionId returns a non-empty string", () => { + const { getSessionId } = require(sessionPath); + const id = getSessionId(); + assert.equal(typeof id, "string"); + assert.ok(id.length >= 32, `expected UUID-length, got ${id}`); +}); + +test("getSessionId is stable within a process", () => { + const { getSessionId } = require(sessionPath); + assert.equal(getSessionId(), getSessionId()); +}); + +test("getSessionId is unique across processes", () => { + const script = `process.stdout.write(require('${sessionPath.replace(/\\/g, "\\\\")}').getSessionId());`; + const a = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + const b = spawnSync(process.execPath, ["-e", script], { encoding: "utf8" }); + assert.notEqual(a.stdout, b.stdout); + assert.ok(a.stdout.length >= 32); +}); From 8676d3b54417711c40dce2545ff611fed85fb5fc Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 15:45:27 +0530 Subject: [PATCH 07/55] feat(telemetry): add consent read/write with schema-version gating Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/consent.js | 71 ++++++++++++++++++++++ shared/telemetry/tests/consent.test.js | 84 ++++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 shared/telemetry/lib/consent.js create mode 100644 shared/telemetry/tests/consent.test.js diff --git a/shared/telemetry/lib/consent.js b/shared/telemetry/lib/consent.js new file mode 100644 index 000000000..f15941f27 --- /dev/null +++ b/shared/telemetry/lib/consent.js @@ -0,0 +1,71 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const SCHEMA_VERSION = 1; +const PROMPT_VERSION = 1; +const FILE_NAME = "telemetry.json"; + +function defaultConfigDir() { + return path.join(os.homedir(), ".power-platform-skills"); +} + +function filePath(configDir) { + return path.join(configDir || defaultConfigDir(), FILE_NAME); +} + +function read({ configDir, env } = {}) { + const e = env || process.env; + if (e.POWER_PLATFORM_SKILLS_TELEMETRY === "0") { + return { state: "disabled", record: null }; + } + + let raw; + try { + raw = fs.readFileSync(filePath(configDir), "utf8"); + } catch { + return { state: "unset" }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { state: "unset" }; + } + + if ( + parsed.version !== SCHEMA_VERSION || + parsed.prompt_version !== PROMPT_VERSION + ) { + return { state: "unset" }; + } + + return { + state: parsed.enabled ? "enabled" : "disabled", + record: parsed, + }; +} + +function write({ configDir, enabled }) { + const dir = configDir || defaultConfigDir(); + fs.mkdirSync(dir, { recursive: true }); + const record = { + version: SCHEMA_VERSION, + prompt_version: PROMPT_VERSION, + enabled: Boolean(enabled), + consented_at: new Date().toISOString(), + }; + fs.writeFileSync(filePath(dir), JSON.stringify(record, null, 2), "utf8"); + return record; +} + +module.exports = { + SCHEMA_VERSION, + PROMPT_VERSION, + defaultConfigDir, + read, + write, +}; diff --git a/shared/telemetry/tests/consent.test.js b/shared/telemetry/tests/consent.test.js new file mode 100644 index 000000000..5f1c807a2 --- /dev/null +++ b/shared/telemetry/tests/consent.test.js @@ -0,0 +1,84 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const consentLib = require("../lib/consent"); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-consent-")); +} + +test("read returns { state: 'unset' } when file missing", () => { + const tmp = mkTmp(); + const result = consentLib.read({ configDir: tmp }); + assert.deepEqual(result, { state: "unset" }); +}); + +test("read returns { state: 'unset' } when file is malformed JSON", () => { + const tmp = mkTmp(); + fs.writeFileSync(path.join(tmp, "telemetry.json"), "{not json"); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "unset"); +}); + +test("write followed by read round-trips", () => { + const tmp = mkTmp(); + consentLib.write({ configDir: tmp, enabled: true }); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "enabled"); + assert.equal(result.record.enabled, true); + assert.equal(result.record.version, 1); + assert.equal(result.record.prompt_version, 1); + assert.ok(result.record.consented_at); +}); + +test("write enabled=false produces state: 'disabled'", () => { + const tmp = mkTmp(); + consentLib.write({ configDir: tmp, enabled: false }); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "disabled"); + assert.equal(result.record.enabled, false); +}); + +test("read treats schema version bump as 'unset' (forces re-prompt)", () => { + const tmp = mkTmp(); + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ version: 2, enabled: true, prompt_version: 1, consented_at: "x" }) + ); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "unset"); +}); + +test("read treats prompt_version bump as 'unset' (forces re-prompt)", () => { + const tmp = mkTmp(); + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ version: 1, enabled: true, prompt_version: 2, consented_at: "x" }) + ); + const result = consentLib.read({ configDir: tmp }); + assert.equal(result.state, "unset"); +}); + +test("env var POWER_PLATFORM_SKILLS_TELEMETRY=0 overrides to 'disabled'", () => { + const tmp = mkTmp(); + consentLib.write({ configDir: tmp, enabled: true }); + const result = consentLib.read({ + configDir: tmp, + env: { POWER_PLATFORM_SKILLS_TELEMETRY: "0" }, + }); + assert.equal(result.state, "disabled"); +}); + +test("env var POWER_PLATFORM_SKILLS_TELEMETRY=1 does NOT force-enable", () => { + const tmp = mkTmp(); + const result = consentLib.read({ + configDir: tmp, + env: { POWER_PLATFORM_SKILLS_TELEMETRY: "1" }, + }); + assert.equal(result.state, "unset"); +}); From 38487c6eff063d5d4d1b1ed6deeb3b057bcf0711 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 16:08:50 +0530 Subject: [PATCH 08/55] =?UTF-8?q?feat(telemetry):=20add=20pre=E2=86=92post?= =?UTF-8?q?=20correlation=20via=20OS=20temp=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/correlation.js | 55 +++++++++++++++++++++ shared/telemetry/tests/correlation.test.js | 56 ++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 shared/telemetry/lib/correlation.js create mode 100644 shared/telemetry/tests/correlation.test.js diff --git a/shared/telemetry/lib/correlation.js b/shared/telemetry/lib/correlation.js new file mode 100644 index 000000000..deff852df --- /dev/null +++ b/shared/telemetry/lib/correlation.js @@ -0,0 +1,55 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function correlationPath({ skillName, tmpDir }) { + const dir = tmpDir || os.tmpdir(); + const safe = String(skillName || "unknown").replace(/[^a-z0-9-]/gi, "_"); + return path.join(dir, `ppskills-corr-${safe}.json`); +} + +function write({ skillName, tmpDir }) { + const record = { + correlation_id: crypto.randomUUID(), + start_ts: Date.now(), + }; + try { + fs.writeFileSync( + correlationPath({ skillName, tmpDir }), + JSON.stringify(record), + "utf8" + ); + } catch { + // fail closed + } + return record; +} + +function read({ skillName, tmpDir }) { + try { + const raw = fs.readFileSync(correlationPath({ skillName, tmpDir }), "utf8"); + const parsed = JSON.parse(raw); + if ( + typeof parsed.correlation_id === "string" && + typeof parsed.start_ts === "number" + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clear({ skillName, tmpDir }) { + try { + fs.unlinkSync(correlationPath({ skillName, tmpDir })); + } catch { + // ignore + } +} + +module.exports = { correlationPath, write, read, clear }; diff --git a/shared/telemetry/tests/correlation.test.js b/shared/telemetry/tests/correlation.test.js new file mode 100644 index 000000000..498fff89e --- /dev/null +++ b/shared/telemetry/tests/correlation.test.js @@ -0,0 +1,56 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const corr = require("../lib/correlation"); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-corr-")); +} + +test("write then read returns the same correlation_id and start_ts", () => { + const tmp = mkTmp(); + const written = corr.write({ + skillName: "create-site", + tmpDir: tmp, + }); + assert.equal(typeof written.correlation_id, "string"); + assert.ok(written.correlation_id.length >= 32); + assert.equal(typeof written.start_ts, "number"); + + const read = corr.read({ skillName: "create-site", tmpDir: tmp }); + assert.equal(read.correlation_id, written.correlation_id); + assert.equal(read.start_ts, written.start_ts); +}); + +test("read returns null when file missing", () => { + const tmp = mkTmp(); + const read = corr.read({ skillName: "does-not-exist", tmpDir: tmp }); + assert.equal(read, null); +}); + +test("read returns null when file malformed", () => { + const tmp = mkTmp(); + fs.writeFileSync( + path.join(tmp, "ppskills-corr-x.json"), + "not json" + ); + const read = corr.read({ skillName: "x", tmpDir: tmp }); + assert.equal(read, null); +}); + +test("clear removes the correlation file", () => { + const tmp = mkTmp(); + corr.write({ skillName: "x", tmpDir: tmp }); + corr.clear({ skillName: "x", tmpDir: tmp }); + assert.equal(corr.read({ skillName: "x", tmpDir: tmp }), null); +}); + +test("clear on missing file does not throw", () => { + const tmp = mkTmp(); + corr.clear({ skillName: "never-written", tmpDir: tmp }); +}); From 2d9b00c05bd03eb1795cbf748923dec87a73d3fe Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 16:12:19 +0530 Subject: [PATCH 09/55] feat(telemetry): add no-op scrubber placeholder Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/scrubber.js | 11 +++++++++++ shared/telemetry/tests/scrubber.test.js | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 shared/telemetry/lib/scrubber.js create mode 100644 shared/telemetry/tests/scrubber.test.js diff --git a/shared/telemetry/lib/scrubber.js b/shared/telemetry/lib/scrubber.js new file mode 100644 index 000000000..517188404 --- /dev/null +++ b/shared/telemetry/lib/scrubber.js @@ -0,0 +1,11 @@ +"use strict"; + +// Placeholder. The spec allowlist already restricts payload fields to values +// that cannot contain PII. This module exists as a documented seam for a +// future regex-based pass if the allowlist ever needs to carry user strings. + +function scrub(value) { + return value; +} + +module.exports = { scrub }; diff --git a/shared/telemetry/tests/scrubber.test.js b/shared/telemetry/tests/scrubber.test.js new file mode 100644 index 000000000..1b4b23031 --- /dev/null +++ b/shared/telemetry/tests/scrubber.test.js @@ -0,0 +1,20 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { scrub } = require("../lib/scrubber"); + +test("scrub returns its input unchanged for strings", () => { + assert.equal(scrub("hello world"), "hello world"); +}); + +test("scrub returns its input unchanged for non-strings", () => { + assert.equal(scrub(42), 42); + assert.equal(scrub(null), null); + assert.equal(scrub(undefined), undefined); +}); + +test("scrub never throws", () => { + scrub({ nested: "obj" }); + scrub([]); +}); From 26d6131d4bcbe7d3bc70a4bd4f86a9285a9ea014 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 16:14:13 +0530 Subject: [PATCH 10/55] feat(telemetry): add strict-allowlist event builders Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/events.js | 77 ++++++++++++++++++++ shared/telemetry/tests/events.test.js | 100 ++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 shared/telemetry/lib/events.js create mode 100644 shared/telemetry/tests/events.test.js diff --git a/shared/telemetry/lib/events.js b/shared/telemetry/lib/events.js new file mode 100644 index 000000000..26620cd1a --- /dev/null +++ b/shared/telemetry/lib/events.js @@ -0,0 +1,77 @@ +"use strict"; + +const COLLECTOR_EVENT_NAME = "PowerPlatformSkillsEvent"; + +const COMMON_FIELDS = [ + "plugin_name", + "plugin_version", + "session_id", + "os_family", + "node_version", + "correlation_id", +]; + +const SKILL_FIELDS = ["skill_name"]; +const SCRIPT_FIELDS = ["script_name"]; +const COMPLETED_FIELDS = ["outcome", "duration_ms", "error_class"]; + +function pick(input, keys) { + const out = {}; + for (const k of keys) { + if (input[k] !== undefined) { + out[k] = input[k]; + } + } + return out; +} + +function clampDuration(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.floor(n); +} + +function envelope(eventName, info) { + if (info.duration_ms !== undefined) { + info.duration_ms = clampDuration(info.duration_ms); + } + return { + name: COLLECTOR_EVENT_NAME, + data: { + eventName, + eventType: "Trace", + severity: "Info", + eventInfo: JSON.stringify(info), + }, + }; +} + +function buildSkillStarted(input) { + return envelope("skill_started", pick(input, [...COMMON_FIELDS, ...SKILL_FIELDS])); +} + +function buildSkillCompleted(input) { + return envelope( + "skill_completed", + pick(input, [...COMMON_FIELDS, ...SKILL_FIELDS, ...COMPLETED_FIELDS]) + ); +} + +function buildScriptStarted(input) { + return envelope("script_started", pick(input, [...COMMON_FIELDS, ...SCRIPT_FIELDS])); +} + +function buildScriptCompleted(input) { + return envelope( + "script_completed", + pick(input, [...COMMON_FIELDS, ...SCRIPT_FIELDS, ...COMPLETED_FIELDS]) + ); +} + +module.exports = { + COLLECTOR_EVENT_NAME, + buildSkillStarted, + buildSkillCompleted, + buildScriptStarted, + buildScriptCompleted, +}; diff --git a/shared/telemetry/tests/events.test.js b/shared/telemetry/tests/events.test.js new file mode 100644 index 000000000..01c0681da --- /dev/null +++ b/shared/telemetry/tests/events.test.js @@ -0,0 +1,100 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + buildSkillStarted, + buildSkillCompleted, + buildScriptStarted, + buildScriptCompleted, + COLLECTOR_EVENT_NAME, +} = require("../lib/events"); + +const common = { + plugin_name: "power-pages", + plugin_version: "1.2.2", + session_id: "sess-uuid", + os_family: "linux", + node_version: "v22", +}; + +test("COLLECTOR_EVENT_NAME is the canonical single collector name", () => { + assert.equal(COLLECTOR_EVENT_NAME, "PowerPlatformSkillsEvent"); +}); + +test("buildSkillStarted emits expected shape", () => { + const ev = buildSkillStarted({ + ...common, + skill_name: "create-site", + correlation_id: "corr-1", + }); + assert.equal(ev.name, COLLECTOR_EVENT_NAME); + assert.equal(ev.data.eventName, "skill_started"); + assert.equal(ev.data.eventType, "Trace"); + assert.equal(ev.data.severity, "Info"); + const info = JSON.parse(ev.data.eventInfo); + assert.deepEqual(Object.keys(info).sort(), [ + "correlation_id", + "node_version", + "os_family", + "plugin_name", + "plugin_version", + "session_id", + "skill_name", + ]); +}); + +test("buildSkillCompleted includes outcome, duration_ms, error_class", () => { + const ev = buildSkillCompleted({ + ...common, + skill_name: "create-site", + correlation_id: "corr-1", + outcome: "success", + duration_ms: 1234, + error_class: "", + }); + assert.equal(ev.data.eventName, "skill_completed"); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.outcome, "success"); + assert.equal(info.duration_ms, 1234); + assert.equal(info.error_class, ""); +}); + +test("builder drops unknown fields (allowlist enforcement)", () => { + const ev = buildSkillStarted({ + ...common, + skill_name: "x", + correlation_id: "c", + tenant_id: "SHOULD_NOT_APPEAR", + file_path: "/etc/passwd", + error_message: "nope", + }); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.tenant_id, undefined); + assert.equal(info.file_path, undefined); + assert.equal(info.error_message, undefined); +}); + +test("buildScriptStarted shape", () => { + const ev = buildScriptStarted({ + ...common, + script_name: "verify-dataverse-access", + correlation_id: "c", + }); + assert.equal(ev.data.eventName, "script_started"); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.script_name, "verify-dataverse-access"); +}); + +test("buildScriptCompleted enforces non-negative duration_ms", () => { + const ev = buildScriptCompleted({ + ...common, + script_name: "s", + correlation_id: "c", + outcome: "failure", + duration_ms: -5, + error_class: "TypeError", + }); + const info = JSON.parse(ev.data.eventInfo); + assert.equal(info.duration_ms, 0); +}); From a6506a06cd331682034bba8ff2f0365e7fe011c7 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 17:05:27 +0530 Subject: [PATCH 11/55] feat(telemetry): add standalone emit-dispatcher child CLI Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/emit-dispatcher.js | 99 +++++++++++++ .../telemetry/tests/emit-dispatcher.test.js | 130 ++++++++++++++++++ 2 files changed, 229 insertions(+) create mode 100644 shared/telemetry/lib/emit-dispatcher.js create mode 100644 shared/telemetry/tests/emit-dispatcher.test.js diff --git a/shared/telemetry/lib/emit-dispatcher.js b/shared/telemetry/lib/emit-dispatcher.js new file mode 100644 index 000000000..420cd8599 --- /dev/null +++ b/shared/telemetry/lib/emit-dispatcher.js @@ -0,0 +1,99 @@ +#!/usr/bin/env node +"use strict"; + +const https = require("node:https"); +const fs = require("node:fs"); + +const PLACEHOLDER_IKEY = "PLACEHOLDER_REPLACE_BEFORE_SHIPPING"; + +const IKEY = process.env.POWER_PLATFORM_SKILLS_IKEY || ""; +const COLLECTOR_URL = process.env.POWER_PLATFORM_SKILLS_COLLECTOR || ""; +const FAKE_PROBE = process.env.POWER_PLATFORM_SKILLS_FAKE_HTTPS || ""; + +function exitSilently() { + process.exit(0); +} + +function readConsent() { + try { + const consent = require("./consent"); + return consent.read({ + configDir: process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined, + }); + } catch { + return { state: "unset" }; + } +} + +function buildEnvelope(event) { + return { + ver: "4.0", + name: event.name, + time: new Date().toISOString(), + iKey: "o:" + IKEY.split("-")[0], + baseType: "Ms.WebClient.TraceEvent", + baseData: event.data, + data: event.data, + }; +} + +function writeProbe(path, { headers, body }) { + try { + fs.writeFileSync(path, JSON.stringify({ headers, body }), "utf8"); + } catch { + // ignore + } +} + +// ---- Gate checks ----------------------------------------------------------- +if (!IKEY || IKEY === PLACEHOLDER_IKEY || !COLLECTOR_URL) exitSilently(); +if (readConsent().state !== "enabled") exitSilently(); + +// ---- Read stdin ------------------------------------------------------------ +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => (raw += c)); +process.stdin.on("end", () => { + let event; + try { + event = JSON.parse(raw); + } catch { + exitSilently(); + } + + const envelope = buildEnvelope(event); + const body = JSON.stringify(envelope); + const headers = { + "Content-Type": "application/x-json-stream; charset=utf-8", + "x-apikey": IKEY, + "Content-Length": Buffer.byteLength(body), + }; + + // Test seam: if POWER_PLATFORM_SKILLS_FAKE_HTTPS is set, write the probe + // payload to that file and exit without calling the real network. + if (FAKE_PROBE) { + writeProbe(FAKE_PROBE, { headers, body }); + exitSilently(); + } + + const url = new URL(COLLECTOR_URL); + const req = https.request( + { + hostname: url.hostname, + path: url.pathname + (url.search || ""), + method: "POST", + headers, + }, + (res) => { + res.on("data", () => {}); + res.on("end", exitSilently); + } + ); + req.on("error", exitSilently); + req.setTimeout(4000, () => { + req.destroy(); + exitSilently(); + }); + req.write(body); + req.end(); +}); diff --git a/shared/telemetry/tests/emit-dispatcher.test.js b/shared/telemetry/tests/emit-dispatcher.test.js new file mode 100644 index 000000000..8b8560954 --- /dev/null +++ b/shared/telemetry/tests/emit-dispatcher.test.js @@ -0,0 +1,130 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const DISPATCHER = path.resolve(__dirname, "../lib/emit-dispatcher.js"); + +function mkConsent(enabled) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-disp-")); + if (enabled !== undefined) { + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); + } + return tmp; +} + +function runDispatcher({ event, env }) { + return spawnSync(process.execPath, [DISPATCHER], { + input: JSON.stringify(event), + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: env.configDir, + POWER_PLATFORM_SKILLS_IKEY: env.iKey || "", + POWER_PLATFORM_SKILLS_COLLECTOR: env.collectorUrl || "", + POWER_PLATFORM_SKILLS_TELEMETRY: env.off ? "0" : "", + POWER_PLATFORM_SKILLS_FAKE_HTTPS: env.fakeProbe || "", + }, + }); +} + +const fakeEvent = { + name: "PowerPlatformSkillsEvent", + data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" }, +}; + +test("dispatcher exits 0 when iKey is placeholder", () => { + const tmp = mkConsent(true); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", collectorUrl: "https://x" }, + }); + assert.equal(status, 0); +}); + +test("dispatcher exits 0 when collector URL missing", () => { + const tmp = mkConsent(true); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "" }, + }); + assert.equal(status, 0); +}); + +test("dispatcher exits 0 when consent disabled", () => { + const tmp = mkConsent(false); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "https://x" }, + }); + assert.equal(status, 0); +}); + +test("dispatcher exits 0 when consent unset", () => { + const tmp = mkConsent(undefined); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "https://x" }, + }); + assert.equal(status, 0); +}); + +test("dispatcher exits 0 when POWER_PLATFORM_SKILLS_TELEMETRY=0", () => { + const tmp = mkConsent(true); + const { status } = runDispatcher({ + event: fakeEvent, + env: { configDir: tmp, iKey: "real-ikey", collectorUrl: "https://x", off: true }, + }); + assert.equal(status, 0); +}); + +test("dispatcher exits 0 on malformed stdin", () => { + const tmp = mkConsent(true); + const { status } = spawnSync(process.execPath, [DISPATCHER], { + input: "not json", + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp, + POWER_PLATFORM_SKILLS_IKEY: "real-ikey", + POWER_PLATFORM_SKILLS_COLLECTOR: "https://x", + }, + }); + assert.equal(status, 0); +}); + +test("dispatcher writes a probe file when fake-https points to one (happy path)", () => { + const tmp = mkConsent(true); + const probePath = path.join(tmp, "probe.json"); + const { status } = runDispatcher({ + event: fakeEvent, + env: { + configDir: tmp, + iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", + collectorUrl: "https://example.invalid/OneCollector/1.0/", + fakeProbe: probePath, + }, + }); + assert.equal(status, 0); + assert.ok(fs.existsSync(probePath), "expected dispatcher to write probe file"); + const probe = JSON.parse(fs.readFileSync(probePath, "utf8")); + assert.equal(probe.headers["x-apikey"], "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa"); + assert.equal(probe.headers["Content-Type"], "application/x-json-stream; charset=utf-8"); + const body = JSON.parse(probe.body); + assert.equal(body.ver, "4.0"); + assert.equal(body.name, "PowerPlatformSkillsEvent"); + assert.equal(body.iKey, "o:real"); + assert.deepEqual(body.data, fakeEvent.data); +}); From 77b1d3ba9af879a777493b1c5401556bc0745902 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 17:09:45 +0530 Subject: [PATCH 12/55] feat(telemetry): harden emit-dispatcher fail-closed guarantees - Global uncaughtException/unhandledRejection/stdin-error handlers ensure the dispatcher never leaves a stack trace or nonzero exit in the parent's process tree. - Wrap new URL(COLLECTOR_URL) in try/catch to handle garbage URLs. - Restore baseType assertion in happy-path test (plan specifies it). - Add HTTPS-refused test to cover the req.on('error') branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/emit-dispatcher.js | 19 ++++++++++++++----- .../telemetry/tests/emit-dispatcher.test.js | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/shared/telemetry/lib/emit-dispatcher.js b/shared/telemetry/lib/emit-dispatcher.js index 420cd8599..47207053b 100644 --- a/shared/telemetry/lib/emit-dispatcher.js +++ b/shared/telemetry/lib/emit-dispatcher.js @@ -4,16 +4,20 @@ const https = require("node:https"); const fs = require("node:fs"); +function exitSilently() { + process.exit(0); +} + +process.on("uncaughtException", exitSilently); +process.on("unhandledRejection", exitSilently); +process.stdin.on("error", exitSilently); + const PLACEHOLDER_IKEY = "PLACEHOLDER_REPLACE_BEFORE_SHIPPING"; const IKEY = process.env.POWER_PLATFORM_SKILLS_IKEY || ""; const COLLECTOR_URL = process.env.POWER_PLATFORM_SKILLS_COLLECTOR || ""; const FAKE_PROBE = process.env.POWER_PLATFORM_SKILLS_FAKE_HTTPS || ""; -function exitSilently() { - process.exit(0); -} - function readConsent() { try { const consent = require("./consent"); @@ -76,7 +80,12 @@ process.stdin.on("end", () => { exitSilently(); } - const url = new URL(COLLECTOR_URL); + let url; + try { + url = new URL(COLLECTOR_URL); + } catch { + return exitSilently(); + } const req = https.request( { hostname: url.hostname, diff --git a/shared/telemetry/tests/emit-dispatcher.test.js b/shared/telemetry/tests/emit-dispatcher.test.js index 8b8560954..bbe31f4f5 100644 --- a/shared/telemetry/tests/emit-dispatcher.test.js +++ b/shared/telemetry/tests/emit-dispatcher.test.js @@ -126,5 +126,19 @@ test("dispatcher writes a probe file when fake-https points to one (happy path)" assert.equal(body.ver, "4.0"); assert.equal(body.name, "PowerPlatformSkillsEvent"); assert.equal(body.iKey, "o:real"); + assert.equal(body.baseType, "Ms.WebClient.TraceEvent"); assert.deepEqual(body.data, fakeEvent.data); }); + +test("dispatcher exits 0 when HTTPS connect is refused", () => { + const tmp = mkConsent(true); + const { status } = runDispatcher({ + event: fakeEvent, + env: { + configDir: tmp, + iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", + collectorUrl: "https://127.0.0.1:1/OneCollector/1.0/", + }, + }); + assert.equal(status, 0); +}); From 2a99ddba354f1122c55914a6f7d0b8bd1f4d1220 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 17:12:00 +0530 Subject: [PATCH 13/55] feat(telemetry): add emit-spawn helper for detached dispatcher Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/emit-spawn.js | 38 +++++++++++++ shared/telemetry/tests/emit-spawn.test.js | 69 +++++++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 shared/telemetry/lib/emit-spawn.js create mode 100644 shared/telemetry/tests/emit-spawn.test.js diff --git a/shared/telemetry/lib/emit-spawn.js b/shared/telemetry/lib/emit-spawn.js new file mode 100644 index 000000000..b9bebcb90 --- /dev/null +++ b/shared/telemetry/lib/emit-spawn.js @@ -0,0 +1,38 @@ +"use strict"; + +const { spawn } = require("node:child_process"); +const path = require("node:path"); + +const DISPATCHER = path.resolve(__dirname, "emit-dispatcher.js"); + +function fireAndForget(event, opts = {}) { + const iKey = opts.iKey || ""; + const collectorUrl = opts.collectorUrl || ""; + const configDir = opts.configDir || ""; + const fakeProbe = opts.fakeProbe || ""; + + try { + const child = spawn("node", [DISPATCHER], { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + env: { + ...process.env, + POWER_PLATFORM_SKILLS_IKEY: iKey, + POWER_PLATFORM_SKILLS_COLLECTOR: collectorUrl, + POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, + POWER_PLATFORM_SKILLS_FAKE_HTTPS: fakeProbe, + }, + }); + try { + child.stdin.write(JSON.stringify(event)); + child.stdin.end(); + } catch { + // child may have already exited; swallow. + } + child.unref(); + } catch { + // spawn failed — fail closed. + } +} + +module.exports = { fireAndForget }; diff --git a/shared/telemetry/tests/emit-spawn.test.js b/shared/telemetry/tests/emit-spawn.test.js new file mode 100644 index 000000000..62d304e55 --- /dev/null +++ b/shared/telemetry/tests/emit-spawn.test.js @@ -0,0 +1,69 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const { fireAndForget } = require("../lib/emit-spawn"); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-spawn-")); +} + +function mkConsent(tmp, enabled) { + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); +} + +test("fireAndForget returns synchronously (<100 ms)", () => { + const tmp = mkTmp(); + const start = Date.now(); + fireAndForget( + { name: "PowerPlatformSkillsEvent", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }, + { iKey: "real-ikey", collectorUrl: "https://example.invalid/" } + ); + const elapsed = Date.now() - start; + assert.ok(elapsed < 100, `expected <100ms, got ${elapsed}ms`); +}); + +test("dispatcher child receives the event and writes the probe", async () => { + const tmp = mkTmp(); + mkConsent(tmp, true); + const probe = path.join(tmp, "probe.json"); + fireAndForget( + { name: "PowerPlatformSkillsEvent", data: { eventName: "hello", eventType: "Trace", severity: "Info", eventInfo: "{}" } }, + { + iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa", + collectorUrl: "https://example.invalid/OneCollector/1.0/", + configDir: tmp, + fakeProbe: probe, + } + ); + // Wait up to 2s for the child to write the probe. + for (let i = 0; i < 20; i++) { + if (fs.existsSync(probe)) break; + await new Promise((r) => setTimeout(r, 100)); + } + assert.ok(fs.existsSync(probe), "probe file was not written"); + const contents = JSON.parse(fs.readFileSync(probe, "utf8")); + const body = JSON.parse(contents.body); + assert.equal(body.data.eventName, "hello"); +}); + +test("fireAndForget does not throw when spawn fails (missing dispatcher path)", () => { + // Rename the dispatcher so spawn will fail + const { fireAndForget: broken } = require("../lib/emit-spawn"); + // Intentionally pass a malformed event that would crash if JSON.stringify throws + // (it won't — objects with cycles would, but we just verify no throw on happy path) + broken({ name: "X", data: {} }, { iKey: "", collectorUrl: "" }); + // No assertion needed: test passes if no throw. +}); From 78a3094da543c37f460f1f2da47a1542aeaead2e Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 17:15:55 +0530 Subject: [PATCH 14/55] feat(telemetry): harden emit-spawn env scope + use execPath - Use process.execPath instead of 'node' so the detached child always runs on the same interpreter as the parent, regardless of PATH. - Replace ...process.env spread with an explicit allowlist so the dispatcher child never inherits unrelated secrets (AZURE tokens, GitHub tokens, etc.). - Minor test cleanups. Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/emit-spawn.js | 11 +++++++++-- shared/telemetry/tests/emit-spawn.test.js | 9 ++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/shared/telemetry/lib/emit-spawn.js b/shared/telemetry/lib/emit-spawn.js index b9bebcb90..5c04c3395 100644 --- a/shared/telemetry/lib/emit-spawn.js +++ b/shared/telemetry/lib/emit-spawn.js @@ -12,11 +12,18 @@ function fireAndForget(event, opts = {}) { const fakeProbe = opts.fakeProbe || ""; try { - const child = spawn("node", [DISPATCHER], { + const child = spawn(process.execPath, [DISPATCHER], { detached: true, stdio: ["pipe", "ignore", "ignore"], env: { - ...process.env, + // Pass only the minimum env the dispatcher needs. Avoid spreading + // process.env so secrets (AZURE_CLIENT_SECRET, GITHUB_TOKEN, etc.) + // never reach the telemetry child. + PATH: process.env.PATH || "", + SystemRoot: process.env.SystemRoot || "", + HOME: process.env.HOME || "", + USERPROFILE: process.env.USERPROFILE || "", + APPDATA: process.env.APPDATA || "", POWER_PLATFORM_SKILLS_IKEY: iKey, POWER_PLATFORM_SKILLS_COLLECTOR: collectorUrl, POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, diff --git a/shared/telemetry/tests/emit-spawn.test.js b/shared/telemetry/tests/emit-spawn.test.js index 62d304e55..8ccd4db64 100644 --- a/shared/telemetry/tests/emit-spawn.test.js +++ b/shared/telemetry/tests/emit-spawn.test.js @@ -25,7 +25,6 @@ function mkConsent(tmp, enabled) { } test("fireAndForget returns synchronously (<100 ms)", () => { - const tmp = mkTmp(); const start = Date.now(); fireAndForget( { name: "PowerPlatformSkillsEvent", data: { eventName: "x", eventType: "Trace", severity: "Info", eventInfo: "{}" } }, @@ -59,11 +58,7 @@ test("dispatcher child receives the event and writes the probe", async () => { assert.equal(body.data.eventName, "hello"); }); -test("fireAndForget does not throw when spawn fails (missing dispatcher path)", () => { - // Rename the dispatcher so spawn will fail - const { fireAndForget: broken } = require("../lib/emit-spawn"); - // Intentionally pass a malformed event that would crash if JSON.stringify throws - // (it won't — objects with cycles would, but we just verify no throw on happy path) - broken({ name: "X", data: {} }, { iKey: "", collectorUrl: "" }); +test("fireAndForget does not throw on empty-opts invocation", () => { + fireAndForget({ name: "X", data: {} }, { iKey: "", collectorUrl: "" }); // No assertion needed: test passes if no throw. }); From bbdab5c5a6222e2baa41ff3fc0371da63b1ceb9c Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 17:30:41 +0530 Subject: [PATCH 15/55] feat(telemetry): add withTelemetry wrapper using fireAndForget Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/with-telemetry.js | 73 +++++++++++++++ shared/telemetry/tests/with-telemetry.test.js | 88 +++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 shared/telemetry/lib/with-telemetry.js create mode 100644 shared/telemetry/tests/with-telemetry.test.js diff --git a/shared/telemetry/lib/with-telemetry.js b/shared/telemetry/lib/with-telemetry.js new file mode 100644 index 000000000..3f540a5ea --- /dev/null +++ b/shared/telemetry/lib/with-telemetry.js @@ -0,0 +1,73 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { getSessionId } = require("./session"); +const { buildScriptStarted, buildScriptCompleted } = require("./events"); +const { fireAndForget } = require("./emit-spawn"); + +function commonFields({ pluginName, pluginVersion }) { + return { + plugin_name: pluginName, + plugin_version: pluginVersion, + session_id: getSessionId(), + os_family: process.platform, + node_version: "v" + String(process.versions.node).split(".")[0], + }; +} + +function defaultEmitter(event, spawnOpts) { + fireAndForget(event, spawnOpts); +} + +async function withTelemetry(scriptName, asyncFn, opts = {}) { + const pluginName = opts.pluginName; + const pluginVersion = opts.pluginVersion; + const emitter = opts.emitter || defaultEmitter; + const spawnOpts = opts.spawnOpts || {}; + const correlationId = crypto.randomUUID(); + const startTs = Date.now(); + + try { + emitter( + buildScriptStarted({ + ...commonFields({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + }), + spawnOpts + ); + } catch { + // fail closed — never let telemetry throw + } + + let outcome = "success"; + let errorClass = ""; + let caught; + try { + return await asyncFn(); + } catch (err) { + outcome = "failure"; + errorClass = err && err.constructor ? err.constructor.name : "Error"; + caught = err; + } finally { + const duration_ms = Date.now() - startTs; + try { + emitter( + buildScriptCompleted({ + ...commonFields({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + outcome, + duration_ms, + error_class: errorClass, + }), + spawnOpts + ); + } catch { + // fail closed + } + if (caught) throw caught; + } +} + +module.exports = { withTelemetry }; diff --git a/shared/telemetry/tests/with-telemetry.test.js b/shared/telemetry/tests/with-telemetry.test.js new file mode 100644 index 000000000..471cf24f7 --- /dev/null +++ b/shared/telemetry/tests/with-telemetry.test.js @@ -0,0 +1,88 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { withTelemetry } = require("../lib/with-telemetry"); + +function recorder() { + const events = []; + return { + events, + emit: (e) => events.push(e), + }; +} + +test("success path emits script_started and script_completed", async () => { + const rec = recorder(); + const result = await withTelemetry( + "verify-dataverse-access", + async () => 42, + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + assert.equal(result, 42); + assert.equal(rec.events.length, 2); + assert.equal(rec.events[0].data.eventName, "script_started"); + assert.equal(rec.events[1].data.eventName, "script_completed"); + const info = JSON.parse(rec.events[1].data.eventInfo); + assert.equal(info.outcome, "success"); + assert.equal(info.error_class, ""); +}); + +test("failure path emits script_completed with outcome=failure and rethrows", async () => { + const rec = recorder(); + await assert.rejects( + withTelemetry( + "x", + async () => { + throw new TypeError("boom"); + }, + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } + ), + TypeError + ); + assert.equal(rec.events.length, 2); + const info = JSON.parse(rec.events[1].data.eventInfo); + assert.equal(info.outcome, "failure"); + assert.equal(info.error_class, "TypeError"); +}); + +test("same correlation_id on started and completed", async () => { + const rec = recorder(); + await withTelemetry( + "x", + async () => null, + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + const a = JSON.parse(rec.events[0].data.eventInfo).correlation_id; + const b = JSON.parse(rec.events[1].data.eventInfo).correlation_id; + assert.equal(a, b); + assert.ok(a.length >= 32); +}); + +test("emit is called synchronously before asyncFn starts (fire-and-forget)", async () => { + const rec = recorder(); + let asyncFnSeenEventsAtStart = -1; + await withTelemetry( + "x", + async () => { + asyncFnSeenEventsAtStart = rec.events.length; + return null; + }, + { emitter: rec.emit, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + // script_started must have been emitted before asyncFn ran. + assert.equal(asyncFnSeenEventsAtStart, 1); +}); + +test("throwing emitter does not break the wrapper", async () => { + const throwingEmitter = () => { + throw new Error("emit blew up"); + }; + const result = await withTelemetry( + "x", + async () => 99, + { emitter: throwingEmitter, pluginName: "power-pages", pluginVersion: "1.2.2" } + ); + assert.equal(result, 99); +}); From b46a0ed76f739279e3f99cf1940142a2463b85bc Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 17:52:11 +0530 Subject: [PATCH 16/55] feat(telemetry): add check-consent CLI Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/check-consent.js | 17 ++++++++++++ shared/telemetry/tests/consent.test.js | 36 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 shared/telemetry/lib/check-consent.js diff --git a/shared/telemetry/lib/check-consent.js b/shared/telemetry/lib/check-consent.js new file mode 100644 index 000000000..498ae9c9a --- /dev/null +++ b/shared/telemetry/lib/check-consent.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +"use strict"; + +const consent = require("./consent"); + +const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; +const result = consent.read({ configDir }); + +const word = + result.state === "enabled" + ? "ENABLED" + : result.state === "disabled" + ? "DISABLED" + : "NEEDS_PROMPT"; + +process.stdout.write(word + "\n"); +process.exit(0); diff --git a/shared/telemetry/tests/consent.test.js b/shared/telemetry/tests/consent.test.js index 5f1c807a2..e8373eba5 100644 --- a/shared/telemetry/tests/consent.test.js +++ b/shared/telemetry/tests/consent.test.js @@ -5,6 +5,7 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); +const { spawnSync } = require("node:child_process"); const consentLib = require("../lib/consent"); @@ -82,3 +83,38 @@ test("env var POWER_PLATFORM_SKILLS_TELEMETRY=1 does NOT force-enable", () => { }); assert.equal(result.state, "unset"); }); + +test("check-consent CLI prints NEEDS_PROMPT when file missing", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/check-consent.js"); + const { stdout, status } = spawnSync(process.execPath, [cli], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(stdout.trim(), "NEEDS_PROMPT"); +}); + +test("check-consent CLI prints ENABLED when file has enabled=true", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + consentLib.write({ configDir: tmp, enabled: true }); + const cli = path.resolve(__dirname, "../lib/check-consent.js"); + const { stdout, status } = spawnSync(process.execPath, [cli], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(stdout.trim(), "ENABLED"); +}); + +test("check-consent CLI prints DISABLED when file has enabled=false", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + consentLib.write({ configDir: tmp, enabled: false }); + const cli = path.resolve(__dirname, "../lib/check-consent.js"); + const { stdout, status } = spawnSync(process.execPath, [cli], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(stdout.trim(), "DISABLED"); +}); From bbc050b454103704b14eef96788cd52aca010be1 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 17:52:45 +0530 Subject: [PATCH 17/55] feat(telemetry): add record-consent CLI Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/lib/record-consent.js | 17 ++++++++++++++ shared/telemetry/tests/consent.test.js | 32 ++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 shared/telemetry/lib/record-consent.js diff --git a/shared/telemetry/lib/record-consent.js b/shared/telemetry/lib/record-consent.js new file mode 100644 index 000000000..093b2381a --- /dev/null +++ b/shared/telemetry/lib/record-consent.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +"use strict"; + +const consent = require("./consent"); + +const args = process.argv.slice(2); +const answerIdx = args.indexOf("--answer"); +const answer = answerIdx !== -1 ? args[answerIdx + 1] : null; + +if (answer !== "yes" && answer !== "no") { + process.stderr.write('Usage: record-consent.js --answer yes|no\n'); + process.exit(2); +} + +const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; +consent.write({ configDir, enabled: answer === "yes" }); +process.exit(0); diff --git a/shared/telemetry/tests/consent.test.js b/shared/telemetry/tests/consent.test.js index e8373eba5..ffef5c41f 100644 --- a/shared/telemetry/tests/consent.test.js +++ b/shared/telemetry/tests/consent.test.js @@ -118,3 +118,35 @@ test("check-consent CLI prints DISABLED when file has enabled=false", () => { assert.equal(status, 0); assert.equal(stdout.trim(), "DISABLED"); }); + +test("record-consent CLI --answer yes writes enabled=true", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/record-consent.js"); + const { status } = spawnSync(process.execPath, [cli, "--answer", "yes"], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(consentLib.read({ configDir: tmp }).state, "enabled"); +}); + +test("record-consent CLI --answer no writes enabled=false", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/record-consent.js"); + const { status } = spawnSync(process.execPath, [cli, "--answer", "no"], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.equal(status, 0); + assert.equal(consentLib.read({ configDir: tmp }).state, "disabled"); +}); + +test("record-consent CLI exits non-zero on invalid --answer", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-cli-")); + const cli = path.resolve(__dirname, "../lib/record-consent.js"); + const { status } = spawnSync(process.execPath, [cli, "--answer", "maybe"], { + env: { ...process.env, POWER_PLATFORM_SKILLS_CONFIG_DIR: tmp }, + encoding: "utf8", + }); + assert.notEqual(status, 0); +}); From d696c954292a02ed079fda4a3614267f076a4307 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:00:51 +0530 Subject: [PATCH 18/55] feat(telemetry): add sync-to-plugin script Co-Authored-By: Claude Opus 4.7 (1M context) --- shared/telemetry/sync-to-plugin.js | 52 +++++++++++++ shared/telemetry/tests/sync-to-plugin.test.js | 74 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 shared/telemetry/sync-to-plugin.js create mode 100644 shared/telemetry/tests/sync-to-plugin.test.js diff --git a/shared/telemetry/sync-to-plugin.js b/shared/telemetry/sync-to-plugin.js new file mode 100644 index 000000000..ff30bff11 --- /dev/null +++ b/shared/telemetry/sync-to-plugin.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("node:fs"); +const path = require("node:path"); + +function getArg(name) { + const i = process.argv.indexOf(`--${name}`); + return i !== -1 && i + 1 < process.argv.length ? process.argv[i + 1] : null; +} + +const target = getArg("target"); +if (!target) { + process.stderr.write("Usage: sync-to-plugin.js --target \n"); + process.exit(1); +} + +const source = path.resolve(__dirname); + +function copyFile(from, to) { + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(from, to); +} + +function copyDir(from, to) { + for (const entry of fs.readdirSync(from, { withFileTypes: true })) { + const src = path.join(from, entry.name); + const dst = path.join(to, entry.name); + if (entry.isDirectory()) copyDir(src, dst); + else copyFile(src, dst); + } +} + +function safeCopyFile(from, to) { + if (fs.existsSync(from)) copyFile(from, to); +} + +// 1. Library + iKey config → /scripts/lib/telemetry/ +const telemetryDst = path.join(target, "scripts", "lib", "telemetry"); +fs.mkdirSync(telemetryDst, { recursive: true }); + +copyDir(path.join(source, "lib"), path.join(telemetryDst, "lib")); +copyFile(path.join(source, "ikey.json"), path.join(telemetryDst, "ikey.json")); + +// 2. Reference doc → /references/ +safeCopyFile( + path.join(source, "references", "telemetry-consent-reference.md"), + path.join(target, "references", "telemetry-consent-reference.md") +); + +process.stdout.write(`Synced shared/telemetry → ${telemetryDst}\n`); +process.exit(0); diff --git a/shared/telemetry/tests/sync-to-plugin.test.js b/shared/telemetry/tests/sync-to-plugin.test.js new file mode 100644 index 000000000..14231b0d1 --- /dev/null +++ b/shared/telemetry/tests/sync-to-plugin.test.js @@ -0,0 +1,74 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +function mkTargetPlugin() { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-sync-")); + fs.mkdirSync(path.join(tmp, "scripts"), { recursive: true }); + fs.mkdirSync(path.join(tmp, "references"), { recursive: true }); + fs.mkdirSync(path.join(tmp, ".claude-plugin"), { recursive: true }); + fs.writeFileSync( + path.join(tmp, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "test-plugin", version: "0.0.1" }) + ); + return tmp; +} + +const syncScript = path.resolve(__dirname, "../sync-to-plugin.js"); + +test("sync copies lib/ and ikey.json into /scripts/lib/telemetry/", () => { + const target = mkTargetPlugin(); + const { status, stderr } = spawnSync( + process.execPath, + [syncScript, "--target", target], + { encoding: "utf8" } + ); + assert.equal(status, 0, stderr); + const synced = path.join(target, "scripts", "lib", "telemetry"); + assert.ok(fs.existsSync(path.join(synced, "ikey.json"))); + assert.ok(fs.existsSync(path.join(synced, "lib", "emit-dispatcher.js"))); + assert.ok(fs.existsSync(path.join(synced, "lib", "emit-spawn.js"))); + assert.ok(fs.existsSync(path.join(synced, "lib", "check-consent.js"))); + assert.ok(!fs.existsSync(path.join(synced, "package.json")), "no package.json should be synced"); +}); + +test("sync copies references/telemetry-consent-reference.md into /references/", () => { + // Prepare a fake ref doc in shared/telemetry/references/ + const refPath = path.resolve( + __dirname, + "../references/telemetry-consent-reference.md" + ); + fs.mkdirSync(path.dirname(refPath), { recursive: true }); + if (!fs.existsSync(refPath)) fs.writeFileSync(refPath, "# ref"); + + const target = mkTargetPlugin(); + const { status } = spawnSync( + process.execPath, + [syncScript, "--target", target], + { encoding: "utf8" } + ); + assert.equal(status, 0); + assert.ok( + fs.existsSync( + path.join(target, "references", "telemetry-consent-reference.md") + ) + ); +}); + +test("sync is idempotent", () => { + const target = mkTargetPlugin(); + spawnSync(process.execPath, [syncScript, "--target", target]); + spawnSync(process.execPath, [syncScript, "--target", target]); + const p = path.join(target, "scripts", "lib", "telemetry", "lib", "emit-dispatcher.js"); + assert.ok(fs.existsSync(p)); +}); + +test("sync exits non-zero on missing --target", () => { + const { status } = spawnSync(process.execPath, [syncScript], { encoding: "utf8" }); + assert.notEqual(status, 0); +}); From 1ed23e863b864aac363512c238f4c8295fa537cb Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:04:18 +0530 Subject: [PATCH 19/55] docs(telemetry): add consent reference doc Co-Authored-By: Claude Opus 4.7 (1M context) --- .../references/telemetry-consent-reference.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 shared/telemetry/references/telemetry-consent-reference.md diff --git a/shared/telemetry/references/telemetry-consent-reference.md b/shared/telemetry/references/telemetry-consent-reference.md new file mode 100644 index 000000000..6edc12cee --- /dev/null +++ b/shared/telemetry/references/telemetry-consent-reference.md @@ -0,0 +1,33 @@ +# Telemetry Consent Reference + +Every tracked Power Pages skill runs this check in Phase 1 before any other work. + +## Phase-1 one-liner for SKILL.md + +Add this line immediately after the existing plugin-version check: + +```markdown +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user with the wording below, then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. +``` + +## Prompt wording + +When `check-consent.js` prints `NEEDS_PROMPT`, use AskUserQuestion with: + +- **Question:** "Share anonymous usage telemetry with Microsoft?" +- **Body:** "The power-pages plugin can send anonymous usage signals (skill name, success/failure, duration, OS family, plugin version) to Microsoft to help improve these tools. No paths, inputs, tenant data, or error messages are sent. Your answer is saved at `~/.power-platform-skills/telemetry.json`; edit that file any time to change it." +- **Options:** + - `"Yes, enable telemetry"` — runs `record-consent.js --answer yes` + - `"No, keep it off"` — runs `record-consent.js --answer no` + +## What is and is not sent + +Sent (allowlist): +- `plugin_name`, `plugin_version`, `session_id` (random per-process UUID), `os_family`, `node_version`, `correlation_id`, `skill_name` or `script_name`, `outcome`, `duration_ms`, `error_class` (constructor name only). + +Never sent: +- File paths, cwd, env vars (except the telemetry off-switch), tenant IDs, site names, site URLs, Dataverse URLs, error messages, stack traces, skill arguments, tool inputs, usernames. + +## Override + +Setting `POWER_PLATFORM_SKILLS_TELEMETRY=0` disables emission regardless of the file. Any other value is ignored — the env var is a one-way off switch. From ddc4052b1315c3995fc665d93f7e3f343b22fa36 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:04:48 +0530 Subject: [PATCH 20/55] feat(power-pages): sync shared telemetry library into plugin Co-Authored-By: Claude Opus 4.7 (1M context) --- .../references/telemetry-consent-reference.md | 33 ++++++ .../scripts/lib/telemetry/ikey.json | 4 + .../lib/telemetry/lib/check-consent.js | 17 +++ .../scripts/lib/telemetry/lib/consent.js | 71 ++++++++++++ .../scripts/lib/telemetry/lib/correlation.js | 55 +++++++++ .../lib/telemetry/lib/emit-dispatcher.js | 108 ++++++++++++++++++ .../scripts/lib/telemetry/lib/emit-spawn.js | 45 ++++++++ .../scripts/lib/telemetry/lib/events.js | 77 +++++++++++++ .../lib/telemetry/lib/record-consent.js | 17 +++ .../scripts/lib/telemetry/lib/scrubber.js | 11 ++ .../scripts/lib/telemetry/lib/session.js | 14 +++ .../lib/telemetry/lib/with-telemetry.js | 73 ++++++++++++ 12 files changed, 525 insertions(+) create mode 100644 plugins/power-pages/references/telemetry-consent-reference.md create mode 100644 plugins/power-pages/scripts/lib/telemetry/ikey.json create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/check-consent.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/consent.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/correlation.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/emit-dispatcher.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/emit-spawn.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/events.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/record-consent.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/scrubber.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/session.js create mode 100644 plugins/power-pages/scripts/lib/telemetry/lib/with-telemetry.js diff --git a/plugins/power-pages/references/telemetry-consent-reference.md b/plugins/power-pages/references/telemetry-consent-reference.md new file mode 100644 index 000000000..6edc12cee --- /dev/null +++ b/plugins/power-pages/references/telemetry-consent-reference.md @@ -0,0 +1,33 @@ +# Telemetry Consent Reference + +Every tracked Power Pages skill runs this check in Phase 1 before any other work. + +## Phase-1 one-liner for SKILL.md + +Add this line immediately after the existing plugin-version check: + +```markdown +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user with the wording below, then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. +``` + +## Prompt wording + +When `check-consent.js` prints `NEEDS_PROMPT`, use AskUserQuestion with: + +- **Question:** "Share anonymous usage telemetry with Microsoft?" +- **Body:** "The power-pages plugin can send anonymous usage signals (skill name, success/failure, duration, OS family, plugin version) to Microsoft to help improve these tools. No paths, inputs, tenant data, or error messages are sent. Your answer is saved at `~/.power-platform-skills/telemetry.json`; edit that file any time to change it." +- **Options:** + - `"Yes, enable telemetry"` — runs `record-consent.js --answer yes` + - `"No, keep it off"` — runs `record-consent.js --answer no` + +## What is and is not sent + +Sent (allowlist): +- `plugin_name`, `plugin_version`, `session_id` (random per-process UUID), `os_family`, `node_version`, `correlation_id`, `skill_name` or `script_name`, `outcome`, `duration_ms`, `error_class` (constructor name only). + +Never sent: +- File paths, cwd, env vars (except the telemetry off-switch), tenant IDs, site names, site URLs, Dataverse URLs, error messages, stack traces, skill arguments, tool inputs, usernames. + +## Override + +Setting `POWER_PLATFORM_SKILLS_TELEMETRY=0` disables emission regardless of the file. Any other value is ignored — the env var is a one-way off switch. diff --git a/plugins/power-pages/scripts/lib/telemetry/ikey.json b/plugins/power-pages/scripts/lib/telemetry/ikey.json new file mode 100644 index 000000000..034523db8 --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/ikey.json @@ -0,0 +1,4 @@ +{ + "ikey": "PLACEHOLDER_REPLACE_BEFORE_SHIPPING", + "collector_url": "https://self.events.data.microsoft.com/OneCollector/1.0/" +} diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/check-consent.js b/plugins/power-pages/scripts/lib/telemetry/lib/check-consent.js new file mode 100644 index 000000000..498ae9c9a --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/check-consent.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +"use strict"; + +const consent = require("./consent"); + +const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; +const result = consent.read({ configDir }); + +const word = + result.state === "enabled" + ? "ENABLED" + : result.state === "disabled" + ? "DISABLED" + : "NEEDS_PROMPT"; + +process.stdout.write(word + "\n"); +process.exit(0); diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/consent.js b/plugins/power-pages/scripts/lib/telemetry/lib/consent.js new file mode 100644 index 000000000..f15941f27 --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/consent.js @@ -0,0 +1,71 @@ +"use strict"; + +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const SCHEMA_VERSION = 1; +const PROMPT_VERSION = 1; +const FILE_NAME = "telemetry.json"; + +function defaultConfigDir() { + return path.join(os.homedir(), ".power-platform-skills"); +} + +function filePath(configDir) { + return path.join(configDir || defaultConfigDir(), FILE_NAME); +} + +function read({ configDir, env } = {}) { + const e = env || process.env; + if (e.POWER_PLATFORM_SKILLS_TELEMETRY === "0") { + return { state: "disabled", record: null }; + } + + let raw; + try { + raw = fs.readFileSync(filePath(configDir), "utf8"); + } catch { + return { state: "unset" }; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return { state: "unset" }; + } + + if ( + parsed.version !== SCHEMA_VERSION || + parsed.prompt_version !== PROMPT_VERSION + ) { + return { state: "unset" }; + } + + return { + state: parsed.enabled ? "enabled" : "disabled", + record: parsed, + }; +} + +function write({ configDir, enabled }) { + const dir = configDir || defaultConfigDir(); + fs.mkdirSync(dir, { recursive: true }); + const record = { + version: SCHEMA_VERSION, + prompt_version: PROMPT_VERSION, + enabled: Boolean(enabled), + consented_at: new Date().toISOString(), + }; + fs.writeFileSync(filePath(dir), JSON.stringify(record, null, 2), "utf8"); + return record; +} + +module.exports = { + SCHEMA_VERSION, + PROMPT_VERSION, + defaultConfigDir, + read, + write, +}; diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/correlation.js b/plugins/power-pages/scripts/lib/telemetry/lib/correlation.js new file mode 100644 index 000000000..deff852df --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/correlation.js @@ -0,0 +1,55 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +function correlationPath({ skillName, tmpDir }) { + const dir = tmpDir || os.tmpdir(); + const safe = String(skillName || "unknown").replace(/[^a-z0-9-]/gi, "_"); + return path.join(dir, `ppskills-corr-${safe}.json`); +} + +function write({ skillName, tmpDir }) { + const record = { + correlation_id: crypto.randomUUID(), + start_ts: Date.now(), + }; + try { + fs.writeFileSync( + correlationPath({ skillName, tmpDir }), + JSON.stringify(record), + "utf8" + ); + } catch { + // fail closed + } + return record; +} + +function read({ skillName, tmpDir }) { + try { + const raw = fs.readFileSync(correlationPath({ skillName, tmpDir }), "utf8"); + const parsed = JSON.parse(raw); + if ( + typeof parsed.correlation_id === "string" && + typeof parsed.start_ts === "number" + ) { + return parsed; + } + return null; + } catch { + return null; + } +} + +function clear({ skillName, tmpDir }) { + try { + fs.unlinkSync(correlationPath({ skillName, tmpDir })); + } catch { + // ignore + } +} + +module.exports = { correlationPath, write, read, clear }; diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/emit-dispatcher.js b/plugins/power-pages/scripts/lib/telemetry/lib/emit-dispatcher.js new file mode 100644 index 000000000..47207053b --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/emit-dispatcher.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +"use strict"; + +const https = require("node:https"); +const fs = require("node:fs"); + +function exitSilently() { + process.exit(0); +} + +process.on("uncaughtException", exitSilently); +process.on("unhandledRejection", exitSilently); +process.stdin.on("error", exitSilently); + +const PLACEHOLDER_IKEY = "PLACEHOLDER_REPLACE_BEFORE_SHIPPING"; + +const IKEY = process.env.POWER_PLATFORM_SKILLS_IKEY || ""; +const COLLECTOR_URL = process.env.POWER_PLATFORM_SKILLS_COLLECTOR || ""; +const FAKE_PROBE = process.env.POWER_PLATFORM_SKILLS_FAKE_HTTPS || ""; + +function readConsent() { + try { + const consent = require("./consent"); + return consent.read({ + configDir: process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined, + }); + } catch { + return { state: "unset" }; + } +} + +function buildEnvelope(event) { + return { + ver: "4.0", + name: event.name, + time: new Date().toISOString(), + iKey: "o:" + IKEY.split("-")[0], + baseType: "Ms.WebClient.TraceEvent", + baseData: event.data, + data: event.data, + }; +} + +function writeProbe(path, { headers, body }) { + try { + fs.writeFileSync(path, JSON.stringify({ headers, body }), "utf8"); + } catch { + // ignore + } +} + +// ---- Gate checks ----------------------------------------------------------- +if (!IKEY || IKEY === PLACEHOLDER_IKEY || !COLLECTOR_URL) exitSilently(); +if (readConsent().state !== "enabled") exitSilently(); + +// ---- Read stdin ------------------------------------------------------------ +let raw = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (c) => (raw += c)); +process.stdin.on("end", () => { + let event; + try { + event = JSON.parse(raw); + } catch { + exitSilently(); + } + + const envelope = buildEnvelope(event); + const body = JSON.stringify(envelope); + const headers = { + "Content-Type": "application/x-json-stream; charset=utf-8", + "x-apikey": IKEY, + "Content-Length": Buffer.byteLength(body), + }; + + // Test seam: if POWER_PLATFORM_SKILLS_FAKE_HTTPS is set, write the probe + // payload to that file and exit without calling the real network. + if (FAKE_PROBE) { + writeProbe(FAKE_PROBE, { headers, body }); + exitSilently(); + } + + let url; + try { + url = new URL(COLLECTOR_URL); + } catch { + return exitSilently(); + } + const req = https.request( + { + hostname: url.hostname, + path: url.pathname + (url.search || ""), + method: "POST", + headers, + }, + (res) => { + res.on("data", () => {}); + res.on("end", exitSilently); + } + ); + req.on("error", exitSilently); + req.setTimeout(4000, () => { + req.destroy(); + exitSilently(); + }); + req.write(body); + req.end(); +}); diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/emit-spawn.js b/plugins/power-pages/scripts/lib/telemetry/lib/emit-spawn.js new file mode 100644 index 000000000..5c04c3395 --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/emit-spawn.js @@ -0,0 +1,45 @@ +"use strict"; + +const { spawn } = require("node:child_process"); +const path = require("node:path"); + +const DISPATCHER = path.resolve(__dirname, "emit-dispatcher.js"); + +function fireAndForget(event, opts = {}) { + const iKey = opts.iKey || ""; + const collectorUrl = opts.collectorUrl || ""; + const configDir = opts.configDir || ""; + const fakeProbe = opts.fakeProbe || ""; + + try { + const child = spawn(process.execPath, [DISPATCHER], { + detached: true, + stdio: ["pipe", "ignore", "ignore"], + env: { + // Pass only the minimum env the dispatcher needs. Avoid spreading + // process.env so secrets (AZURE_CLIENT_SECRET, GITHUB_TOKEN, etc.) + // never reach the telemetry child. + PATH: process.env.PATH || "", + SystemRoot: process.env.SystemRoot || "", + HOME: process.env.HOME || "", + USERPROFILE: process.env.USERPROFILE || "", + APPDATA: process.env.APPDATA || "", + POWER_PLATFORM_SKILLS_IKEY: iKey, + POWER_PLATFORM_SKILLS_COLLECTOR: collectorUrl, + POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, + POWER_PLATFORM_SKILLS_FAKE_HTTPS: fakeProbe, + }, + }); + try { + child.stdin.write(JSON.stringify(event)); + child.stdin.end(); + } catch { + // child may have already exited; swallow. + } + child.unref(); + } catch { + // spawn failed — fail closed. + } +} + +module.exports = { fireAndForget }; diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/events.js b/plugins/power-pages/scripts/lib/telemetry/lib/events.js new file mode 100644 index 000000000..26620cd1a --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/events.js @@ -0,0 +1,77 @@ +"use strict"; + +const COLLECTOR_EVENT_NAME = "PowerPlatformSkillsEvent"; + +const COMMON_FIELDS = [ + "plugin_name", + "plugin_version", + "session_id", + "os_family", + "node_version", + "correlation_id", +]; + +const SKILL_FIELDS = ["skill_name"]; +const SCRIPT_FIELDS = ["script_name"]; +const COMPLETED_FIELDS = ["outcome", "duration_ms", "error_class"]; + +function pick(input, keys) { + const out = {}; + for (const k of keys) { + if (input[k] !== undefined) { + out[k] = input[k]; + } + } + return out; +} + +function clampDuration(ms) { + const n = Number(ms); + if (!Number.isFinite(n) || n < 0) return 0; + return Math.floor(n); +} + +function envelope(eventName, info) { + if (info.duration_ms !== undefined) { + info.duration_ms = clampDuration(info.duration_ms); + } + return { + name: COLLECTOR_EVENT_NAME, + data: { + eventName, + eventType: "Trace", + severity: "Info", + eventInfo: JSON.stringify(info), + }, + }; +} + +function buildSkillStarted(input) { + return envelope("skill_started", pick(input, [...COMMON_FIELDS, ...SKILL_FIELDS])); +} + +function buildSkillCompleted(input) { + return envelope( + "skill_completed", + pick(input, [...COMMON_FIELDS, ...SKILL_FIELDS, ...COMPLETED_FIELDS]) + ); +} + +function buildScriptStarted(input) { + return envelope("script_started", pick(input, [...COMMON_FIELDS, ...SCRIPT_FIELDS])); +} + +function buildScriptCompleted(input) { + return envelope( + "script_completed", + pick(input, [...COMMON_FIELDS, ...SCRIPT_FIELDS, ...COMPLETED_FIELDS]) + ); +} + +module.exports = { + COLLECTOR_EVENT_NAME, + buildSkillStarted, + buildSkillCompleted, + buildScriptStarted, + buildScriptCompleted, +}; diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/record-consent.js b/plugins/power-pages/scripts/lib/telemetry/lib/record-consent.js new file mode 100644 index 000000000..093b2381a --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/record-consent.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +"use strict"; + +const consent = require("./consent"); + +const args = process.argv.slice(2); +const answerIdx = args.indexOf("--answer"); +const answer = answerIdx !== -1 ? args[answerIdx + 1] : null; + +if (answer !== "yes" && answer !== "no") { + process.stderr.write('Usage: record-consent.js --answer yes|no\n'); + process.exit(2); +} + +const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || undefined; +consent.write({ configDir, enabled: answer === "yes" }); +process.exit(0); diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/scrubber.js b/plugins/power-pages/scripts/lib/telemetry/lib/scrubber.js new file mode 100644 index 000000000..517188404 --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/scrubber.js @@ -0,0 +1,11 @@ +"use strict"; + +// Placeholder. The spec allowlist already restricts payload fields to values +// that cannot contain PII. This module exists as a documented seam for a +// future regex-based pass if the allowlist ever needs to carry user strings. + +function scrub(value) { + return value; +} + +module.exports = { scrub }; diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/session.js b/plugins/power-pages/scripts/lib/telemetry/lib/session.js new file mode 100644 index 000000000..4507fffac --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/session.js @@ -0,0 +1,14 @@ +"use strict"; + +const crypto = require("node:crypto"); + +let cached; + +function getSessionId() { + if (!cached) { + cached = crypto.randomUUID(); + } + return cached; +} + +module.exports = { getSessionId }; diff --git a/plugins/power-pages/scripts/lib/telemetry/lib/with-telemetry.js b/plugins/power-pages/scripts/lib/telemetry/lib/with-telemetry.js new file mode 100644 index 000000000..3f540a5ea --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry/lib/with-telemetry.js @@ -0,0 +1,73 @@ +"use strict"; + +const crypto = require("node:crypto"); +const { getSessionId } = require("./session"); +const { buildScriptStarted, buildScriptCompleted } = require("./events"); +const { fireAndForget } = require("./emit-spawn"); + +function commonFields({ pluginName, pluginVersion }) { + return { + plugin_name: pluginName, + plugin_version: pluginVersion, + session_id: getSessionId(), + os_family: process.platform, + node_version: "v" + String(process.versions.node).split(".")[0], + }; +} + +function defaultEmitter(event, spawnOpts) { + fireAndForget(event, spawnOpts); +} + +async function withTelemetry(scriptName, asyncFn, opts = {}) { + const pluginName = opts.pluginName; + const pluginVersion = opts.pluginVersion; + const emitter = opts.emitter || defaultEmitter; + const spawnOpts = opts.spawnOpts || {}; + const correlationId = crypto.randomUUID(); + const startTs = Date.now(); + + try { + emitter( + buildScriptStarted({ + ...commonFields({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + }), + spawnOpts + ); + } catch { + // fail closed — never let telemetry throw + } + + let outcome = "success"; + let errorClass = ""; + let caught; + try { + return await asyncFn(); + } catch (err) { + outcome = "failure"; + errorClass = err && err.constructor ? err.constructor.name : "Error"; + caught = err; + } finally { + const duration_ms = Date.now() - startTs; + try { + emitter( + buildScriptCompleted({ + ...commonFields({ pluginName, pluginVersion }), + script_name: scriptName, + correlation_id: correlationId, + outcome, + duration_ms, + error_class: errorClass, + }), + spawnOpts + ); + } catch { + // fail closed + } + if (caught) throw caught; + } +} + +module.exports = { withTelemetry }; From 54e619abdebd52e73e733740227c2ebf65c06c75 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:06:45 +0530 Subject: [PATCH 21/55] feat(power-pages): add PreToolUse:Skill telemetry hook Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hooks/run-skill-pretool-telemetry.js | 95 +++++++++++++++++++ .../tests/telemetry-hook-pretool.test.js | 73 ++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 plugins/power-pages/hooks/run-skill-pretool-telemetry.js create mode 100644 plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js diff --git a/plugins/power-pages/hooks/run-skill-pretool-telemetry.js b/plugins/power-pages/hooks/run-skill-pretool-telemetry.js new file mode 100644 index 000000000..a055fce02 --- /dev/null +++ b/plugins/power-pages/hooks/run-skill-pretool-telemetry.js @@ -0,0 +1,95 @@ +#!/usr/bin/env node +"use strict"; + +const path = require("node:path"); +const fs = require("node:fs"); + +const PLUGIN_ROOT = path.resolve(__dirname, ".."); +const TELEMETRY_DIR = path.join(PLUGIN_ROOT, "scripts", "lib", "telemetry"); + +let emitSpawn, eventsLib, correlationLib, sessionLib; +try { + emitSpawn = require(path.join(TELEMETRY_DIR, "lib", "emit-spawn")); + eventsLib = require(path.join(TELEMETRY_DIR, "lib", "events")); + correlationLib = require(path.join(TELEMETRY_DIR, "lib", "correlation")); + sessionLib = require(path.join(TELEMETRY_DIR, "lib", "session")); +} catch { + process.exit(0); +} + +let hookUtils; +try { + hookUtils = require(path.join(PLUGIN_ROOT, "scripts", "lib", "powerpages-hook-utils")); +} catch { + process.exit(0); +} + +function readPluginVersion() { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"), "utf8") + ); + return manifest.version || "unknown"; + } catch { + return "unknown"; + } +} + +function readIkey() { + try { + const cfg = JSON.parse( + fs.readFileSync(path.join(TELEMETRY_DIR, "ikey.json"), "utf8") + ); + return { ikey: cfg.ikey, collectorUrl: cfg.collector_url }; + } catch { + return { ikey: "", collectorUrl: "" }; + } +} + +function readStdin() { + return new Promise((resolve) => { + let buf = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (c) => (buf += c)); + process.stdin.on("end", () => resolve(buf)); + process.stdin.on("error", () => resolve(buf)); + }); +} + +(async () => { + const raw = await readStdin(); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + process.exit(0); + } + + const skillName = hookUtils.getTrackedSkillFromToolInput(parsed.tool_input); + if (!skillName) process.exit(0); + + const { correlation_id } = correlationLib.write({ skillName }); + + const { ikey, collectorUrl } = readIkey(); + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || ""; + + try { + emitSpawn.fireAndForget( + eventsLib.buildSkillStarted({ + plugin_name: "power-pages", + plugin_version: readPluginVersion(), + session_id: sessionLib.getSessionId(), + os_family: process.platform, + node_version: "v" + String(process.versions.node).split(".")[0], + skill_name: skillName, + correlation_id, + }), + { iKey: ikey, collectorUrl, configDir } + ); + } catch { + // fail closed + } + + // Parent exits immediately; dispatcher child carries the POST. + process.exit(0); +})().catch(() => process.exit(0)); diff --git a/plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js b/plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js new file mode 100644 index 000000000..2f496d26b --- /dev/null +++ b/plugins/power-pages/scripts/tests/telemetry-hook-pretool.test.js @@ -0,0 +1,73 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const HOOK = path.resolve( + __dirname, + "../../hooks/run-skill-pretool-telemetry.js" +); + +function mkConfigDir(enabled) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-ph-")); + if (enabled !== undefined) { + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); + } + return tmp; +} + +function runHook({ input, configDir }) { + return spawnSync(process.execPath, [HOOK], { + input, + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, + }, + }); +} + +test("exits 0 and emits nothing when tool_input has no tracked skill", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "other-plugin:foo" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); + +test("exits 0 when consent unset", () => { + const tmp = mkConfigDir(undefined); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "create-site" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); + +test("exits 0 when malformed stdin", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ input: "{not json", configDir: tmp }); + assert.equal(status, 0); +}); + +test("exits 0 even when consent enabled and skill tracked (placeholder iKey → no-op emit)", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "create-site" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); From 4da89a169fe49ad8e76ec3aecf143ba2e6ac255b Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:14:41 +0530 Subject: [PATCH 22/55] feat(power-pages): emit skill_completed from existing PostToolUse hook Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hooks/run-skill-posttool-validation.js | 103 ++++++++++++++---- .../tests/telemetry-hook-posttool.test.js | 56 ++++++++++ 2 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js diff --git a/plugins/power-pages/hooks/run-skill-posttool-validation.js b/plugins/power-pages/hooks/run-skill-posttool-validation.js index 760cb6b53..162f965a3 100644 --- a/plugins/power-pages/hooks/run-skill-posttool-validation.js +++ b/plugins/power-pages/hooks/run-skill-posttool-validation.js @@ -1,12 +1,15 @@ #!/usr/bin/env node const path = require('path'); +const fs = require('fs'); const { spawnSync } = require('child_process'); const { getTrackedSkillFromToolInput, getValidatorScript, } = require('../scripts/lib/powerpages-hook-utils'); +const PLUGIN_ROOT = path.resolve(__dirname, '..'); +const TELEMETRY_DIR = path.join(PLUGIN_ROOT, 'scripts', 'lib', 'telemetry'); const DEBUG = process.env.DEBUG === '1' || process.env.DEBUG === 'true'; function debug(msg) { @@ -21,43 +24,97 @@ process.stdin.on('data', (chunk) => { inputData += chunk; }); -process.stdin.on('end', () => { +process.stdin.on('end', async () => { debug(`[power-pages hook] stdin closed, received ${inputData.length} bytes\n`); + + const startTs = Date.now(); + let validatorStatus = 0; + let skillName = null; + let validatorRan = false; + try { const input = JSON.parse(inputData); - const skillName = getTrackedSkillFromToolInput(input.tool_input); + skillName = getTrackedSkillFromToolInput(input.tool_input); if (!skillName) { debug('[power-pages hook] No tracked skill detected — skipping validation\n'); process.exit(0); } const validatorScript = getValidatorScript(skillName); - if (!validatorScript) { - debug(`[power-pages hook] Skill "${skillName}" has no validator — skipping\n`); - process.exit(0); + if (validatorScript) { + validatorRan = true; + const validatorPath = path.join(__dirname, '..', validatorScript); + const result = spawnSync(process.execPath, [validatorPath], { + input: inputData, + encoding: 'utf8', + cwd: input.cwd || process.cwd(), + }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + validatorStatus = result.status ?? 0; + debug(`[power-pages hook] Validator exited with code ${validatorStatus}\n`); } + } catch (err) { + process.stderr.write(`[power-pages hook] Unexpected error: ${err.message}\n`); + validatorStatus = 0; + } + + // Telemetry emission: fail-closed, never changes exit code. + try { + const emitSpawn = require(path.join(TELEMETRY_DIR, 'lib', 'emit-spawn')); + const eventsLib = require(path.join(TELEMETRY_DIR, 'lib', 'events')); + const correlationLib = require(path.join(TELEMETRY_DIR, 'lib', 'correlation')); + const sessionLib = require(path.join(TELEMETRY_DIR, 'lib', 'session')); - debug(`[power-pages hook] Running validator for skill "${skillName}": ${validatorScript}\n`); + const ikeyCfg = (() => { + try { + return JSON.parse( + fs.readFileSync(path.join(TELEMETRY_DIR, 'ikey.json'), 'utf8') + ); + } catch { + return { ikey: '', collector_url: '' }; + } + })(); - const validatorPath = path.join(__dirname, '..', validatorScript); - const result = spawnSync(process.execPath, [validatorPath], { - input: inputData, - encoding: 'utf8', - cwd: input.cwd || process.cwd(), - }); + const pluginVersion = (() => { + try { + return JSON.parse( + fs.readFileSync(path.join(PLUGIN_ROOT, '.claude-plugin', 'plugin.json'), 'utf8') + ).version || 'unknown'; + } catch { + return 'unknown'; + } + })(); - if (result.stdout) { - process.stdout.write(result.stdout); - } + const corr = correlationLib.read({ skillName }) || { + correlation_id: require('crypto').randomUUID(), + start_ts: startTs, + }; - if (result.stderr) { - process.stderr.write(result.stderr); - } + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || ''; + const outcome = + !validatorRan || validatorStatus === 0 ? 'success' : 'failure'; - debug(`[power-pages hook] Validator exited with code ${result.status ?? 0}\n`); - process.exit(result.status ?? 0); - } catch (err) { - process.stderr.write(`[power-pages hook] Unexpected error: ${err.message}\n`); - process.exit(0); + emitSpawn.fireAndForget( + eventsLib.buildSkillCompleted({ + plugin_name: 'power-pages', + plugin_version: pluginVersion, + session_id: sessionLib.getSessionId(), + os_family: process.platform, + node_version: 'v' + String(process.versions.node).split('.')[0], + skill_name: skillName, + correlation_id: corr.correlation_id, + outcome, + duration_ms: Date.now() - (corr.start_ts || startTs), + error_class: '', + }), + { iKey: ikeyCfg.ikey, collectorUrl: ikeyCfg.collector_url, configDir } + ); + + correlationLib.clear({ skillName }); + } catch { + // fail closed: telemetry never affects skill outcome } + + process.exit(validatorStatus); }); diff --git a/plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js b/plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js new file mode 100644 index 000000000..ac2fb4995 --- /dev/null +++ b/plugins/power-pages/scripts/tests/telemetry-hook-posttool.test.js @@ -0,0 +1,56 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const HOOK = path.resolve( + __dirname, + "../../hooks/run-skill-posttool-validation.js" +); + +function mkConfigDir(enabled) { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "ppskills-ho-")); + fs.writeFileSync( + path.join(tmp, "telemetry.json"), + JSON.stringify({ + version: 1, + prompt_version: 1, + enabled, + consented_at: new Date().toISOString(), + }) + ); + return tmp; +} + +function runHook({ input, configDir }) { + return spawnSync(process.execPath, [HOOK], { + input, + encoding: "utf8", + env: { + ...process.env, + POWER_PLATFORM_SKILLS_CONFIG_DIR: configDir, + }, + }); +} + +test("posttool hook exits 0 with no tracked skill (preserves existing behavior)", () => { + const tmp = mkConfigDir(true); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "nothing" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); + +test("posttool hook exits 0 when consent disabled (no emit, validator still runs)", () => { + const tmp = mkConfigDir(false); + const { status } = runHook({ + input: JSON.stringify({ tool_input: { skill: "create-site" } }), + configDir: tmp, + }); + assert.equal(status, 0); +}); From 249cd9eef19fdf6d96131d0f99915ac6dd21887e Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:15:32 +0530 Subject: [PATCH 23/55] feat(power-pages): register PreToolUse:Skill telemetry hook Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/power-pages/hooks/hooks.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugins/power-pages/hooks/hooks.json b/plugins/power-pages/hooks/hooks.json index 60b8753b3..06f582a44 100644 --- a/plugins/power-pages/hooks/hooks.json +++ b/plugins/power-pages/hooks/hooks.json @@ -1,5 +1,17 @@ { "hooks": { + "PreToolUse": [ + { + "matcher": "Skill", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/run-skill-pretool-telemetry.js\"", + "timeout": 30 + } + ] + } + ], "PostToolUse": [ { "matcher": "Skill", From d1fdcbd7b03202ef82edd4da061d4785d782fb60 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:16:48 +0530 Subject: [PATCH 24/55] feat(power-pages): add Phase-1 telemetry-consent check to create-site Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/power-pages/skills/create-site/SKILL.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/power-pages/skills/create-site/SKILL.md b/plugins/power-pages/skills/create-site/SKILL.md index edd1d131d..955354ce1 100644 --- a/plugins/power-pages/skills/create-site/SKILL.md +++ b/plugins/power-pages/skills/create-site/SKILL.md @@ -13,6 +13,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Create Power Pages Code Site Guide the user through creating a complete, production-quality Power Pages code site from initial concept to deployed site. Follow a systematic approach: discover requirements, scaffold and launch immediately, plan components and design, implement with design applied, validate, review, and deploy. From 675fccdcbbec2eb1a619e83c9bfd67f59b4cd08a Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:17:36 +0530 Subject: [PATCH 25/55] feat(power-pages): add Phase-1 telemetry-consent check to remaining tracked skills Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/power-pages/skills/activate-site/SKILL.md | 2 ++ plugins/power-pages/skills/add-cloud-flow/SKILL.md | 2 ++ plugins/power-pages/skills/add-sample-data/SKILL.md | 2 ++ plugins/power-pages/skills/add-seo/SKILL.md | 2 ++ plugins/power-pages/skills/add-server-logic/SKILL.md | 2 ++ plugins/power-pages/skills/audit-permissions/SKILL.md | 2 ++ plugins/power-pages/skills/create-webroles/SKILL.md | 2 ++ plugins/power-pages/skills/integrate-webapi/SKILL.md | 2 ++ plugins/power-pages/skills/setup-auth/SKILL.md | 2 ++ plugins/power-pages/skills/setup-datamodel/SKILL.md | 2 ++ plugins/power-pages/skills/test-site/SKILL.md | 2 ++ 11 files changed, 22 insertions(+) diff --git a/plugins/power-pages/skills/activate-site/SKILL.md b/plugins/power-pages/skills/activate-site/SKILL.md index ca9cc18e6..0c78a43d1 100644 --- a/plugins/power-pages/skills/activate-site/SKILL.md +++ b/plugins/power-pages/skills/activate-site/SKILL.md @@ -11,6 +11,8 @@ model: sonnet > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Activate Power Pages Site Provision a new Power Pages website in a Power Platform environment via the Power Platform REST API. diff --git a/plugins/power-pages/skills/add-cloud-flow/SKILL.md b/plugins/power-pages/skills/add-cloud-flow/SKILL.md index 1d47a01c5..87958aabc 100644 --- a/plugins/power-pages/skills/add-cloud-flow/SKILL.md +++ b/plugins/power-pages/skills/add-cloud-flow/SKILL.md @@ -13,6 +13,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Add Cloud Flow Connect one or more Power Automate cloud flows to a Power Pages code site, or wire already-registered flows into additional pages/components. For new flows this skill: diff --git a/plugins/power-pages/skills/add-sample-data/SKILL.md b/plugins/power-pages/skills/add-sample-data/SKILL.md index 0188d1ca3..d383a3bd1 100644 --- a/plugins/power-pages/skills/add-sample-data/SKILL.md +++ b/plugins/power-pages/skills/add-sample-data/SKILL.md @@ -11,6 +11,8 @@ model: sonnet > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Add Sample Data Populate Dataverse tables with sample records via OData API so users can test and demo their Power Pages sites. diff --git a/plugins/power-pages/skills/add-seo/SKILL.md b/plugins/power-pages/skills/add-seo/SKILL.md index 60064388a..604608bd3 100644 --- a/plugins/power-pages/skills/add-seo/SKILL.md +++ b/plugins/power-pages/skills/add-seo/SKILL.md @@ -11,6 +11,8 @@ model: sonnet > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Add SEO Add essential SEO assets to a Power Pages code site: `robots.txt`, `sitemap.xml`, and meta tags. diff --git a/plugins/power-pages/skills/add-server-logic/SKILL.md b/plugins/power-pages/skills/add-server-logic/SKILL.md index 9eee64197..3b2387275 100644 --- a/plugins/power-pages/skills/add-server-logic/SKILL.md +++ b/plugins/power-pages/skills/add-server-logic/SKILL.md @@ -13,6 +13,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Add Server Logic Create and manage one or more Power Pages Server Logic files — server-side JavaScript that runs securely on the Power Pages runtime, hidden from the browser and protected by web roles and table permissions. Server Logic enables secure external API integrations, Dataverse operations, and custom business logic without exposing sensitive code or credentials to the client. diff --git a/plugins/power-pages/skills/audit-permissions/SKILL.md b/plugins/power-pages/skills/audit-permissions/SKILL.md index be87da447..16317231f 100644 --- a/plugins/power-pages/skills/audit-permissions/SKILL.md +++ b/plugins/power-pages/skills/audit-permissions/SKILL.md @@ -13,6 +13,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Audit Permissions Audit existing table permissions on a Power Pages code site. Analyze permissions against the site code and Dataverse metadata, then generate a visual HTML audit report with findings, reasoning, and suggested fixes. diff --git a/plugins/power-pages/skills/create-webroles/SKILL.md b/plugins/power-pages/skills/create-webroles/SKILL.md index d87c1a3ca..fd10304d9 100644 --- a/plugins/power-pages/skills/create-webroles/SKILL.md +++ b/plugins/power-pages/skills/create-webroles/SKILL.md @@ -11,6 +11,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Create Web Roles Create web roles for a Power Pages code site. Web roles define the permissions and access levels for different types of site users. diff --git a/plugins/power-pages/skills/integrate-webapi/SKILL.md b/plugins/power-pages/skills/integrate-webapi/SKILL.md index 1170b85cb..6c0a989d3 100644 --- a/plugins/power-pages/skills/integrate-webapi/SKILL.md +++ b/plugins/power-pages/skills/integrate-webapi/SKILL.md @@ -12,6 +12,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Integrate Web API Integrate Power Pages Web API into a code site's frontend. This skill orchestrates the full lifecycle: analyzing where integrations are needed, implementing API client code for each table, configuring permissions and site settings, and deploying the site. diff --git a/plugins/power-pages/skills/setup-auth/SKILL.md b/plugins/power-pages/skills/setup-auth/SKILL.md index 53258492a..580eb02b0 100644 --- a/plugins/power-pages/skills/setup-auth/SKILL.md +++ b/plugins/power-pages/skills/setup-auth/SKILL.md @@ -12,6 +12,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Set Up Authentication & Authorization Configure authentication (login/logout via Microsoft Entra ID) and role-based authorization for a Power Pages code site. This skill creates an auth service, type declarations, authorization utilities, auth UI components, and role-based access control patterns appropriate to the site's framework. diff --git a/plugins/power-pages/skills/setup-datamodel/SKILL.md b/plugins/power-pages/skills/setup-datamodel/SKILL.md index 179af60a0..b5814210d 100644 --- a/plugins/power-pages/skills/setup-datamodel/SKILL.md +++ b/plugins/power-pages/skills/setup-datamodel/SKILL.md @@ -11,6 +11,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Set Up Dataverse Data Model Guide the user through creating Dataverse tables, columns, and relationships for their Power Pages site. Follow a systematic approach: verify prerequisites, obtain a data model (via AI analysis or user-provided diagram), review and approve, then create all schema objects via OData API. diff --git a/plugins/power-pages/skills/test-site/SKILL.md b/plugins/power-pages/skills/test-site/SKILL.md index 6c6b2680c..789050ccb 100644 --- a/plugins/power-pages/skills/test-site/SKILL.md +++ b/plugins/power-pages/skills/test-site/SKILL.md @@ -12,6 +12,8 @@ model: opus > **Plugin check**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/check-version.js"` — if it outputs a message, show it to the user before proceeding. +> **Telemetry consent**: Run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/check-consent.js"` — if the output is `NEEDS_PROMPT`, use AskUserQuestion to ask the user per `${CLAUDE_PLUGIN_ROOT}/references/telemetry-consent-reference.md` and then run `node "${CLAUDE_PLUGIN_ROOT}/scripts/lib/telemetry/lib/record-consent.js" --answer yes|no`. + # Test Power Pages Site Test a deployed, activated Power Pages site at runtime. Navigate the site in a browser, crawl all discoverable links, verify pages load correctly, capture network traffic to test API requests, and generate a comprehensive test report. From 7dd1b5c0bb89b4b1c28f05bf02e2f93412519704 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:24:37 +0530 Subject: [PATCH 26/55] feat(power-pages): add telemetry-runner helper for script instrumentation Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scripts/lib/telemetry-runner.js | 50 +++++++++++++++++++ .../scripts/tests/telemetry-runner.test.js | 21 ++++++++ 2 files changed, 71 insertions(+) create mode 100644 plugins/power-pages/scripts/lib/telemetry-runner.js create mode 100644 plugins/power-pages/scripts/tests/telemetry-runner.test.js diff --git a/plugins/power-pages/scripts/lib/telemetry-runner.js b/plugins/power-pages/scripts/lib/telemetry-runner.js new file mode 100644 index 000000000..844c3cdb6 --- /dev/null +++ b/plugins/power-pages/scripts/lib/telemetry-runner.js @@ -0,0 +1,50 @@ +"use strict"; + +const path = require("node:path"); +const fs = require("node:fs"); + +const PLUGIN_ROOT = path.resolve(__dirname, "..", ".."); +const TELEMETRY_DIR = path.join(PLUGIN_ROOT, "scripts", "lib", "telemetry"); + +function readPluginVersion() { + try { + return JSON.parse( + fs.readFileSync(path.join(PLUGIN_ROOT, ".claude-plugin", "plugin.json"), "utf8") + ).version || "unknown"; + } catch { + return "unknown"; + } +} + +function loadTelemetryDeps() { + try { + return { + withTelemetry: require(path.join(TELEMETRY_DIR, "lib", "with-telemetry")) + .withTelemetry, + ikeyCfg: JSON.parse( + fs.readFileSync(path.join(TELEMETRY_DIR, "ikey.json"), "utf8") + ), + }; + } catch { + return null; + } +} + +async function runInstrumented(scriptName, asyncFn) { + const deps = loadTelemetryDeps(); + if (!deps) return asyncFn(); + + const configDir = process.env.POWER_PLATFORM_SKILLS_CONFIG_DIR || ""; + + return deps.withTelemetry(scriptName, asyncFn, { + pluginName: "power-pages", + pluginVersion: readPluginVersion(), + spawnOpts: { + iKey: deps.ikeyCfg.ikey, + collectorUrl: deps.ikeyCfg.collector_url, + configDir, + }, + }); +} + +module.exports = { runInstrumented }; diff --git a/plugins/power-pages/scripts/tests/telemetry-runner.test.js b/plugins/power-pages/scripts/tests/telemetry-runner.test.js new file mode 100644 index 000000000..26b12e081 --- /dev/null +++ b/plugins/power-pages/scripts/tests/telemetry-runner.test.js @@ -0,0 +1,21 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const path = require("node:path"); + +const { runInstrumented } = require("../lib/telemetry-runner"); + +test("runInstrumented awaits the async fn and returns its value", async () => { + const result = await runInstrumented("dummy-script", async () => 123); + assert.equal(result, 123); +}); + +test("runInstrumented rethrows errors from the fn", async () => { + await assert.rejects( + runInstrumented("dummy-script", async () => { + throw new Error("nope"); + }), + /nope/ + ); +}); From 6967545bc489a029a229d395b17b2dc3b05f4ff4 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:26:42 +0530 Subject: [PATCH 27/55] feat(power-pages): instrument check-activation-status with withTelemetry Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scripts/check-activation-status.js | 116 ++++++++++-------- 1 file changed, 63 insertions(+), 53 deletions(-) diff --git a/plugins/power-pages/scripts/check-activation-status.js b/plugins/power-pages/scripts/check-activation-status.js index f49537186..01eff4751 100644 --- a/plugins/power-pages/scripts/check-activation-status.js +++ b/plugins/power-pages/scripts/check-activation-status.js @@ -16,74 +16,75 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const { findPath, getPacAuthInfo, getAuthToken, makeRequest, CLOUD_TO_API } = require('./lib/validation-helpers'); +const { runInstrumented } = require('./lib/telemetry-runner'); function output(obj) { process.stdout.write(JSON.stringify(obj)); process.exit(0); } -// --- Parse --projectRoot argument --- -const args = process.argv.slice(2); -const rootIdx = args.indexOf('--projectRoot'); -const projectRoot = rootIdx !== -1 ? args[rootIdx + 1] : process.cwd(); +async function main() { + // --- Parse --projectRoot argument --- + const args = process.argv.slice(2); + const rootIdx = args.indexOf('--projectRoot'); + const projectRoot = rootIdx !== -1 ? args[rootIdx + 1] : process.cwd(); -// --- Read siteName from powerpages.config.json --- -const configPath = findPath(projectRoot, 'powerpages.config.json'); -if (!configPath) { - output({ error: 'powerpages.config.json not found' }); -} + // --- Read siteName from powerpages.config.json --- + const configPath = findPath(projectRoot, 'powerpages.config.json'); + if (!configPath) { + output({ error: 'powerpages.config.json not found' }); + } -let siteName; -try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - siteName = config.siteName; -} catch { - output({ error: 'Failed to parse powerpages.config.json' }); -} -if (!siteName) { - output({ error: 'siteName not found in powerpages.config.json' }); -} + let siteName; + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + siteName = config.siteName; + } catch { + output({ error: 'Failed to parse powerpages.config.json' }); + } + if (!siteName) { + output({ error: 'siteName not found in powerpages.config.json' }); + } -// --- Get websiteRecordId from pac pages list --- -let websiteRecordId = null; -try { - const pacOutput = execSync('pac pages list', { encoding: 'utf8', timeout: 15000 }); - // pac pages list outputs a table with columns. Find the row matching siteName. - // Column headers vary but Website Record ID is always a GUID column. - const lines = pacOutput.split(/\r?\n/).filter((l) => l.trim()); - for (const line of lines) { - // Skip header/separator lines - if (line.includes('----') || line.toLowerCase().includes('website name')) continue; - // Check if this line contains our site name (case-insensitive) - if (line.toLowerCase().includes(siteName.toLowerCase())) { - // Extract GUID from the line - const guidMatch = line.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); - if (guidMatch) { - websiteRecordId = guidMatch[0]; + // --- Get websiteRecordId from pac pages list --- + let websiteRecordId = null; + try { + const pacOutput = execSync('pac pages list', { encoding: 'utf8', timeout: 15000 }); + // pac pages list outputs a table with columns. Find the row matching siteName. + // Column headers vary but Website Record ID is always a GUID column. + const lines = pacOutput.split(/\r?\n/).filter((l) => l.trim()); + for (const line of lines) { + // Skip header/separator lines + if (line.includes('----') || line.toLowerCase().includes('website name')) continue; + // Check if this line contains our site name (case-insensitive) + if (line.toLowerCase().includes(siteName.toLowerCase())) { + // Extract GUID from the line + const guidMatch = line.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i); + if (guidMatch) { + websiteRecordId = guidMatch[0]; + } + break; } - break; } + } catch { + // pac pages list failed — continue without websiteRecordId } -} catch { - // pac pages list failed — continue without websiteRecordId -} -// --- Get PAC auth info --- -const pacInfo = getPacAuthInfo(); -if (!pacInfo) { - output({ error: 'PAC CLI not authenticated' }); -} + // --- Get PAC auth info --- + const pacInfo = getPacAuthInfo(); + if (!pacInfo) { + output({ error: 'PAC CLI not authenticated' }); + } -const ppApiBaseUrl = CLOUD_TO_API[pacInfo.cloud] || CLOUD_TO_API['Public']; + const ppApiBaseUrl = CLOUD_TO_API[pacInfo.cloud] || CLOUD_TO_API['Public']; -// --- Get Azure CLI token --- -const token = getAuthToken(ppApiBaseUrl); -if (!token) { - output({ error: 'Azure CLI token not available' }); -} + // --- Get Azure CLI token --- + const token = getAuthToken(ppApiBaseUrl); + if (!token) { + output({ error: 'Azure CLI token not available' }); + } -// --- Query websites API --- -(async () => { + // --- Query websites API --- const websites = await getWebsites(ppApiBaseUrl, token, pacInfo.environmentId); if (websites === null) { output({ error: 'Websites API call failed' }); @@ -114,7 +115,7 @@ if (!token) { websiteRecordId, }); } -})(); +} async function getWebsites(ppApiBaseUrl, token, environmentId) { try { @@ -135,3 +136,12 @@ async function getWebsites(ppApiBaseUrl, token, environmentId) { return null; } } + +if (require.main === module) { + runInstrumented('check-activation-status', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; From 600e5c009cfdeb7ef4bec9e3b485c608b79574d2 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:27:04 +0530 Subject: [PATCH 28/55] feat(power-pages): instrument verify-dataverse-access with withTelemetry Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/power-pages/scripts/verify-dataverse-access.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plugins/power-pages/scripts/verify-dataverse-access.js b/plugins/power-pages/scripts/verify-dataverse-access.js index c8f3ce3ba..e72105760 100644 --- a/plugins/power-pages/scripts/verify-dataverse-access.js +++ b/plugins/power-pages/scripts/verify-dataverse-access.js @@ -6,6 +6,7 @@ // Exit 0 on success, exit 1 on failure (error message on stderr). const { getAuthToken, makeRequest } = require('./lib/validation-helpers'); +const { runInstrumented } = require('./lib/telemetry-runner'); async function main() { const envUrl = process.argv[2]; @@ -55,4 +56,11 @@ async function main() { })); } -main(); +if (require.main === module) { + runInstrumented('verify-dataverse-access', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; From 60758b46bc4ef6f8a34d029dfdecf2be2c099eb0 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:27:33 +0530 Subject: [PATCH 29/55] feat(power-pages): instrument render-audit-report with withTelemetry Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scripts/render-audit-report.js | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/plugins/power-pages/scripts/render-audit-report.js b/plugins/power-pages/scripts/render-audit-report.js index 99eb39b65..a95bf37d6 100644 --- a/plugins/power-pages/scripts/render-audit-report.js +++ b/plugins/power-pages/scripts/render-audit-report.js @@ -11,19 +11,31 @@ const path = require('path'); const { renderTemplate, parseArgs } = require('./lib/render-template'); +const { runInstrumented } = require('./lib/telemetry-runner'); -const args = parseArgs(process.argv); +async function main() { + const args = parseArgs(process.argv); -if (!args.output || !args.data) { - console.error( - 'Usage: node render-audit-report.js --output --data ' - ); - process.exit(1); + if (!args.output || !args.data) { + console.error( + 'Usage: node render-audit-report.js --output --data ' + ); + process.exit(1); + } + + renderTemplate({ + templatePath: path.join(__dirname, '..', 'skills', 'audit-permissions', 'assets', 'audit-report.html'), + outputPath: path.resolve(args.output), + dataPath: path.resolve(args.data), + requiredKeys: ['SITE_NAME', 'AUDIT_DESC', 'SUMMARY', 'FINDINGS_DATA', 'INVENTORY_DATA'], + }); +} + +if (require.main === module) { + runInstrumented('render-audit-report', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); } -renderTemplate({ - templatePath: path.join(__dirname, '..', 'skills', 'audit-permissions', 'assets', 'audit-report.html'), - outputPath: path.resolve(args.output), - dataPath: path.resolve(args.data), - requiredKeys: ['SITE_NAME', 'AUDIT_DESC', 'SUMMARY', 'FINDINGS_DATA', 'INVENTORY_DATA'], -}); +module.exports = { main }; From 8b1242edad478c1a0639cf49e4addd30361ba24e Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:27:57 +0530 Subject: [PATCH 30/55] feat(power-pages): instrument clear-site-cache with withTelemetry Co-Authored-By: Claude Opus 4.7 (1M context) --- .../power-pages/scripts/clear-site-cache.js | 76 +++++++++++-------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/plugins/power-pages/scripts/clear-site-cache.js b/plugins/power-pages/scripts/clear-site-cache.js index df3fbfd8b..bafef4e89 100644 --- a/plugins/power-pages/scripts/clear-site-cache.js +++ b/plugins/power-pages/scripts/clear-site-cache.js @@ -14,50 +14,51 @@ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const { findPath, getPacAuthInfo, getAuthToken, makeRequest, CLOUD_TO_API } = require('./lib/validation-helpers'); +const { runInstrumented } = require('./lib/telemetry-runner'); function output(obj) { process.stdout.write(JSON.stringify(obj)); process.exit(obj.success ? 0 : 1); } -// --- Parse --projectRoot argument --- -const args = process.argv.slice(2); -const rootIdx = args.indexOf('--projectRoot'); -const projectRoot = rootIdx !== -1 ? args[rootIdx + 1] : process.cwd(); +async function main() { + // --- Parse --projectRoot argument --- + const args = process.argv.slice(2); + const rootIdx = args.indexOf('--projectRoot'); + const projectRoot = rootIdx !== -1 ? args[rootIdx + 1] : process.cwd(); -// --- Read siteName from powerpages.config.json --- -const configPath = findPath(projectRoot, 'powerpages.config.json'); -if (!configPath) { - output({ success: false, error: 'powerpages.config.json not found' }); -} + // --- Read siteName from powerpages.config.json --- + const configPath = findPath(projectRoot, 'powerpages.config.json'); + if (!configPath) { + output({ success: false, error: 'powerpages.config.json not found' }); + } -let siteName; -try { - const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); - siteName = config.siteName; -} catch { - output({ success: false, error: 'Failed to parse powerpages.config.json' }); -} -if (!siteName) { - output({ success: false, error: 'siteName not found in powerpages.config.json' }); -} + let siteName; + try { + const config = JSON.parse(fs.readFileSync(configPath, 'utf8')); + siteName = config.siteName; + } catch { + output({ success: false, error: 'Failed to parse powerpages.config.json' }); + } + if (!siteName) { + output({ success: false, error: 'siteName not found in powerpages.config.json' }); + } -// --- Get PAC auth info --- -const pacInfo = getPacAuthInfo(); -if (!pacInfo) { - output({ success: false, error: 'PAC CLI not authenticated' }); -} + // --- Get PAC auth info --- + const pacInfo = getPacAuthInfo(); + if (!pacInfo) { + output({ success: false, error: 'PAC CLI not authenticated' }); + } -const ppApiBaseUrl = CLOUD_TO_API[pacInfo.cloud] || CLOUD_TO_API['Public']; + const ppApiBaseUrl = CLOUD_TO_API[pacInfo.cloud] || CLOUD_TO_API['Public']; -// --- Get Power Platform API token --- -const token = getAuthToken(ppApiBaseUrl); -if (!token) { - output({ success: false, error: 'Failed to get Azure CLI access token. Ensure you are logged in with: az login' }); -} + // --- Get Power Platform API token --- + const token = getAuthToken(ppApiBaseUrl); + if (!token) { + output({ success: false, error: 'Failed to get Azure CLI access token. Ensure you are logged in with: az login' }); + } -// --- Find the website and restart it to clear cache --- -(async () => { + // --- Find the website and restart it to clear cache --- // Get websites for this environment const listResult = await makeRequest({ url: `${ppApiBaseUrl}/powerpages/environments/${pacInfo.environmentId}/websites?api-version=2022-03-01-preview`, @@ -119,4 +120,13 @@ if (!token) { } else { output({ success: false, error: `Restart returned HTTP ${restartResult.statusCode}: ${restartResult.body}` }); } -})(); +} + +if (require.main === module) { + runInstrumented('clear-site-cache', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; From 3131652986753bff55e5b415fda53710cb812d7e Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:39:19 +0530 Subject: [PATCH 31/55] feat(power-pages): instrument skill validators with runInstrumented Wrap all 10 per-skill validator scripts with runInstrumented to emit script_started and script_completed telemetry events. Each validator now calls runInstrumented at the module level, wrapping the runValidation call to track execution through 1DS telemetry. Validators wrapped: - validate-activate-site - validate-add-seo - validate-audit-permissions - validate-create-site - validate-create-webroles - validate-add-cloud-flow - validate-add-server-logic - validate-integrate-webapi - validate-setup-auth - validate-setup-datamodel Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scripts/validate-activation.js | 66 ++++++++------ .../scripts/validate-cloudflow.js | 22 +++-- .../skills/add-seo/scripts/validate-seo.js | 90 ++++++++++--------- .../scripts/validate-serverlogic.js | 31 +++++-- .../scripts/validate-audit.js | 44 +++++---- .../create-site/scripts/validate-site.js | 28 +++--- .../scripts/validate-webroles.js | 38 ++++---- .../scripts/validate-webapi-integration.js | 31 +++++-- .../setup-auth/scripts/validate-auth.js | 31 +++++-- .../scripts/validate-datamodel.js | 31 +++++-- 10 files changed, 264 insertions(+), 148 deletions(-) diff --git a/plugins/power-pages/skills/activate-site/scripts/validate-activation.js b/plugins/power-pages/skills/activate-site/scripts/validate-activation.js index b708f1659..b0f1aa5a9 100644 --- a/plugins/power-pages/skills/activate-site/scripts/validate-activation.js +++ b/plugins/power-pages/skills/activate-site/scripts/validate-activation.js @@ -8,35 +8,43 @@ const path = require('path'); const { execSync } = require('child_process'); const { approve, block, runValidation, findPath } = require('../../../scripts/lib/validation-helpers'); - -runValidation(async (cwd) => { - const configPath = findPath(cwd, 'powerpages.config.json'); - if (!configPath) approve(); // Not a Power Pages project, skip - - const projectRoot = path.dirname(configPath); - const checkScript = path.resolve(__dirname, '../../../scripts/check-activation-status.js'); - - let result; - try { - const output = execSync(`node "${checkScript}" --projectRoot "${projectRoot}"`, { - encoding: 'utf8', - timeout: 30000, - }); - result = JSON.parse(output); - } catch { - approve(); // Auth/transient failure — don't block - } - - if (result.activated === true) { +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); + +async function main() { + return runValidation(async (cwd) => { + const configPath = findPath(cwd, 'powerpages.config.json'); + if (!configPath) approve(); // Not a Power Pages project, skip + + const projectRoot = path.dirname(configPath); + const checkScript = path.resolve(__dirname, '../../../scripts/check-activation-status.js'); + + let result; + try { + const output = execSync(`node "${checkScript}" --projectRoot "${projectRoot}"`, { + encoding: 'utf8', + timeout: 30000, + }); + result = JSON.parse(output); + } catch { + approve(); // Auth/transient failure — don't block + } + + if (result.activated === true) { + approve(); + } + + if (result.activated === false) { + block( + `Power Pages activation validation failed:\n- Site '${result.siteName || 'unknown'}' is not activated. The site may not have been provisioned successfully.` + ); + } + + // Error or unexpected shape — don't block approve(); - } - - if (result.activated === false) { - block( - `Power Pages activation validation failed:\n- Site '${result.siteName || 'unknown'}' is not activated. The site may not have been provisioned successfully.` - ); - } + }); +} - // Error or unexpected shape — don't block - approve(); +runInstrumented('validate-activate-site', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); diff --git a/plugins/power-pages/skills/add-cloud-flow/scripts/validate-cloudflow.js b/plugins/power-pages/skills/add-cloud-flow/scripts/validate-cloudflow.js index 6ef679383..5215ec8e2 100644 --- a/plugins/power-pages/skills/add-cloud-flow/scripts/validate-cloudflow.js +++ b/plugins/power-pages/skills/add-cloud-flow/scripts/validate-cloudflow.js @@ -13,10 +13,12 @@ const { findProjectRoot, UUID_REGEX, } = require('../../../scripts/lib/validation-helpers'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation((cwd) => { - const projectRoot = findProjectRoot(cwd); - if (!projectRoot) return approve(); +async function main() { + return runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd); + if (!projectRoot) return approve(); const cloudFlowDir = path.join(projectRoot, '.powerpages-site', 'cloud-flow-consumer'); if (!fs.existsSync(cloudFlowDir)) return approve(); @@ -115,9 +117,15 @@ runValidation((cwd) => { } } - if (errors.length > 0) { - block('Cloud flow consumer validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('Cloud flow consumer validation failed:\n- ' + errors.join('\n- ')); + } + + approve(); + }); +} - approve(); +runInstrumented('validate-add-cloud-flow', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); diff --git a/plugins/power-pages/skills/add-seo/scripts/validate-seo.js b/plugins/power-pages/skills/add-seo/scripts/validate-seo.js index e1223a037..ac361c58c 100644 --- a/plugins/power-pages/skills/add-seo/scripts/validate-seo.js +++ b/plugins/power-pages/skills/add-seo/scripts/validate-seo.js @@ -6,58 +6,61 @@ const fs = require('fs'); const path = require('path'); const { approve, block, runValidation, findPath } = require('../../../scripts/lib/validation-helpers'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation((cwd) => { - const configPath = findPath(cwd, 'powerpages.config.json'); - if (!configPath) approve(); // Not a Power Pages project, skip +async function main() { + return runValidation((cwd) => { + const configPath = findPath(cwd, 'powerpages.config.json'); + if (!configPath) approve(); // Not a Power Pages project, skip - const projectRoot = path.dirname(configPath); - const publicDir = path.join(projectRoot, 'public'); + const projectRoot = path.dirname(configPath); + const publicDir = path.join(projectRoot, 'public'); - if (!fs.existsSync(publicDir)) approve(); + if (!fs.existsSync(publicDir)) approve(); - // Check if any SEO file exists — if none, this wasn't an SEO session, skip - const hasRobots = fs.existsSync(path.join(publicDir, 'robots.txt')); - const hasSitemap = fs.existsSync(path.join(publicDir, 'sitemap.xml')); - if (!hasRobots && !hasSitemap) approve(); + // Check if any SEO file exists — if none, this wasn't an SEO session, skip + const hasRobots = fs.existsSync(path.join(publicDir, 'robots.txt')); + const hasSitemap = fs.existsSync(path.join(publicDir, 'sitemap.xml')); + if (!hasRobots && !hasSitemap) approve(); - const errors = []; + const errors = []; - // 1. robots.txt - if (!hasRobots) { - errors.push('Missing public/robots.txt'); - } else { - const content = fs.readFileSync(path.join(publicDir, 'robots.txt'), 'utf8'); - if (!content.includes('User-agent:')) errors.push('robots.txt: missing User-agent directive'); - if (!content.toLowerCase().includes('sitemap:')) errors.push('robots.txt: missing Sitemap directive'); - } + // 1. robots.txt + if (!hasRobots) { + errors.push('Missing public/robots.txt'); + } else { + const content = fs.readFileSync(path.join(publicDir, 'robots.txt'), 'utf8'); + if (!content.includes('User-agent:')) errors.push('robots.txt: missing User-agent directive'); + if (!content.toLowerCase().includes('sitemap:')) errors.push('robots.txt: missing Sitemap directive'); + } - // 2. sitemap.xml - if (!hasSitemap) { - errors.push('Missing public/sitemap.xml'); - } else { - const content = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf8'); - if (!content.includes(' element'); - if (!content.includes('')) errors.push('sitemap.xml: missing entries'); - if (content.includes('') || content.includes('')) { - errors.push('sitemap.xml: contains unreplaced template placeholders'); + // 2. sitemap.xml + if (!hasSitemap) { + errors.push('Missing public/sitemap.xml'); + } else { + const content = fs.readFileSync(path.join(publicDir, 'sitemap.xml'), 'utf8'); + if (!content.includes(' element'); + if (!content.includes('')) errors.push('sitemap.xml: missing entries'); + if (content.includes('') || content.includes('')) { + errors.push('sitemap.xml: contains unreplaced template placeholders'); + } } - } - // 3. Meta tags in index.html - const indexPath = findIndexHtml(projectRoot); - if (indexPath) { - const content = fs.readFileSync(indexPath, 'utf8'); - if (!content.includes('meta name="description"')) errors.push('index.html: missing meta description tag'); - if (!content.includes('meta name="viewport"')) errors.push('index.html: missing viewport meta tag'); - } + // 3. Meta tags in index.html + const indexPath = findIndexHtml(projectRoot); + if (indexPath) { + const content = fs.readFileSync(indexPath, 'utf8'); + if (!content.includes('meta name="description"')) errors.push('index.html: missing meta description tag'); + if (!content.includes('meta name="viewport"')) errors.push('index.html: missing viewport meta tag'); + } - if (errors.length > 0) { - block('SEO validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('SEO validation failed:\n- ' + errors.join('\n- ')); + } - approve(); -}); + approve(); + }); +} function findIndexHtml(projectRoot) { const candidates = [ @@ -83,3 +86,8 @@ function findIndexHtml(projectRoot) { return null; } + +runInstrumented('validate-add-seo', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); +}); diff --git a/plugins/power-pages/skills/add-server-logic/scripts/validate-serverlogic.js b/plugins/power-pages/skills/add-server-logic/scripts/validate-serverlogic.js index f68e00dc6..c4872cc98 100644 --- a/plugins/power-pages/skills/add-server-logic/scripts/validate-serverlogic.js +++ b/plugins/power-pages/skills/add-server-logic/scripts/validate-serverlogic.js @@ -7,13 +7,15 @@ const fs = require('fs'); const path = require('path'); const { approve, block, runValidation, findProjectRoot, UUID_REGEX } = require('../../../scripts/lib/validation-helpers'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); const ALLOWED_FUNCTIONS = ['get', 'post', 'put', 'patch', 'del']; const BROWSER_APIS = ['XMLHttpRequest', 'document\\.', 'window\\.', 'setTimeout', 'setInterval', 'navigator\\.', 'fetch']; -runValidation((cwd) => { - const projectRoot = findProjectRoot(cwd); - if (!projectRoot) return approve(); // Not a Power Pages project, skip +async function main() { + return runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd); + if (!projectRoot) return approve(); // Not a Power Pages project, skip // Server logic files live inside .powerpages-site/server-logic/ const serverLogicDir = path.join(projectRoot, '.powerpages-site', 'server-logic'); @@ -204,11 +206,17 @@ runValidation((cwd) => { } } - if (errors.length > 0) { - block('Server Logic validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('Server Logic validation failed:\n- ' + errors.join('\n- ')); + } + + approve(); + }); +} - approve(); +runInstrumented('validate-add-server-logic', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); function findServerLogicDirs(dir) { @@ -312,3 +320,12 @@ function findTopLevelFunctions(content) { } return names; } + +if (require.main === module) { + runInstrumented('validate-add-server-logic', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; diff --git a/plugins/power-pages/skills/audit-permissions/scripts/validate-audit.js b/plugins/power-pages/skills/audit-permissions/scripts/validate-audit.js index 522ed1148..886b6974c 100644 --- a/plugins/power-pages/skills/audit-permissions/scripts/validate-audit.js +++ b/plugins/power-pages/skills/audit-permissions/scripts/validate-audit.js @@ -6,28 +6,36 @@ const fs = require('fs'); const path = require('path'); const { approve, block, runValidation, findPath, findProjectRoot } = require('../../../scripts/lib/validation-helpers'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation((cwd) => { - const projectRoot = findProjectRoot(cwd); - if (!projectRoot) approve(); // Not a Power Pages project — not an audit session +async function main() { + return runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd); + if (!projectRoot) approve(); // Not a Power Pages project — not an audit session - // Check if audit report was generated in docs/ - const docsReport = path.join(projectRoot, 'docs', 'permissions-audit.html'); - if (fs.existsSync(docsReport)) { - const content = fs.readFileSync(docsReport, 'utf8'); - if (content.includes('__FINDINGS_DATA__') || content.includes('__INVENTORY_DATA__')) { - block('Audit report has unreplaced placeholders — data was not populated.'); + // Check if audit report was generated in docs/ + const docsReport = path.join(projectRoot, 'docs', 'permissions-audit.html'); + if (fs.existsSync(docsReport)) { + const content = fs.readFileSync(docsReport, 'utf8'); + if (content.includes('__FINDINGS_DATA__') || content.includes('__INVENTORY_DATA__')) { + block('Audit report has unreplaced placeholders — data was not populated.'); + } + approve(); + } + + // Check temp directory as fallback + const tempDir = process.env.TEMP || process.env.TMP || '/tmp'; + const tempReport = path.join(tempDir, 'permissions-audit.html'); + if (fs.existsSync(tempReport)) { + approve(); } - approve(); - } - // Check temp directory as fallback - const tempDir = process.env.TEMP || process.env.TMP || '/tmp'; - const tempReport = path.join(tempDir, 'permissions-audit.html'); - if (fs.existsSync(tempReport)) { + // No report found — this may not be an audit session, so don't block approve(); - } + }); +} - // No report found — this may not be an audit session, so don't block - approve(); +runInstrumented('validate-audit-permissions', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); diff --git a/plugins/power-pages/skills/create-site/scripts/validate-site.js b/plugins/power-pages/skills/create-site/scripts/validate-site.js index 12794d1ec..1681ad3eb 100644 --- a/plugins/power-pages/skills/create-site/scripts/validate-site.js +++ b/plugins/power-pages/skills/create-site/scripts/validate-site.js @@ -6,13 +6,15 @@ const fs = require('fs'); const path = require('path'); const { approve, block, runValidation, findPath } = require('../../../scripts/lib/validation-helpers'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation((cwd) => { - const configPath = findPath(cwd, 'powerpages.config.json'); - if (!configPath) approve(); // Not a Power Pages project, skip +async function main() { + return runValidation((cwd) => { + const configPath = findPath(cwd, 'powerpages.config.json'); + if (!configPath) approve(); // Not a Power Pages project, skip - const projectRoot = path.dirname(configPath); - const errors = []; + const projectRoot = path.dirname(configPath); + const errors = []; // 1. Required files for (const file of ['package.json', '.gitignore', 'powerpages.config.json']) { @@ -67,12 +69,13 @@ runValidation((cwd) => { errors.push('Missing src/ directory'); } - if (errors.length > 0) { - block('Power Pages site validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('Power Pages site validation failed:\n- ' + errors.join('\n- ')); + } - approve(); -}); + approve(); + }); +} const PLACEHOLDER_RE = /__[A-Z][A-Z_]{2,}__/; @@ -103,3 +106,8 @@ function findPlaceholders(dir) { } catch {} return results; } + +runInstrumented('validate-create-site', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); +}); diff --git a/plugins/power-pages/skills/create-webroles/scripts/validate-webroles.js b/plugins/power-pages/skills/create-webroles/scripts/validate-webroles.js index 8c938fb55..13a1d37e1 100644 --- a/plugins/power-pages/skills/create-webroles/scripts/validate-webroles.js +++ b/plugins/power-pages/skills/create-webroles/scripts/validate-webroles.js @@ -6,23 +6,31 @@ const path = require('path'); const { approve, block, runValidation, findPowerPagesSiteDir } = require('../../../scripts/lib/validation-helpers'); const { validateWebRoles } = require('../../../scripts/lib/web-roles-validator'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation((cwd) => { - const webRolesDir = findPowerPagesSiteDir(cwd, 'web-roles'); - if (!webRolesDir) approve(); // No .powerpages-site found — not a web roles session +async function main() { + return runValidation((cwd) => { + const webRolesDir = findPowerPagesSiteDir(cwd, 'web-roles'); + if (!webRolesDir) approve(); // No .powerpages-site found — not a web roles session - const validation = validateWebRoles(path.resolve(webRolesDir, '..', '..')); - const webRoleFiles = validation.webRoles; - if (webRoleFiles && webRoleFiles.length === 0) { - block('Web roles validation failed:\n- No web role YAML files found in .powerpages-site/web-roles/'); - } - const errors = validation.findings - .filter(finding => finding.severity === 'error') - .map(finding => finding.filePath ? `${finding.message} (${path.basename(finding.filePath)})` : finding.message); + const validation = validateWebRoles(path.resolve(webRolesDir, '..', '..')); + const webRoleFiles = validation.webRoles; + if (webRoleFiles && webRoleFiles.length === 0) { + block('Web roles validation failed:\n- No web role YAML files found in .powerpages-site/web-roles/'); + } + const errors = validation.findings + .filter(finding => finding.severity === 'error') + .map(finding => finding.filePath ? `${finding.message} (${path.basename(finding.filePath)})` : finding.message); - if (errors.length > 0) { - block('Web roles validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('Web roles validation failed:\n- ' + errors.join('\n- ')); + } - approve(); + approve(); + }); +} + +runInstrumented('validate-create-webroles', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); diff --git a/plugins/power-pages/skills/integrate-webapi/scripts/validate-webapi-integration.js b/plugins/power-pages/skills/integrate-webapi/scripts/validate-webapi-integration.js index 515989fea..98ad0a438 100644 --- a/plugins/power-pages/skills/integrate-webapi/scripts/validate-webapi-integration.js +++ b/plugins/power-pages/skills/integrate-webapi/scripts/validate-webapi-integration.js @@ -7,10 +7,12 @@ const fs = require('fs'); const path = require('path'); const { approve, block, runValidation, findProjectRoot } = require('../../../scripts/lib/validation-helpers'); const { validatePowerPagesSchema } = require('../../../scripts/lib/powerpages-schema-validator'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation((cwd) => { - const projectRoot = findProjectRoot(cwd); - if (!projectRoot) approve(); // Not a Power Pages project, skip +async function main() { + return runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd); + if (!projectRoot) approve(); // Not a Power Pages project, skip // Check if any Web API integration files exist — if none, this wasn't an integration session const apiClientExists = findApiClient(projectRoot); @@ -49,11 +51,17 @@ runValidation((cwd) => { errors.push('Invalid Power Pages permissions/site-settings schema:\n - ' + schemaErrors.join('\n - ')); } - if (errors.length > 0) { - block('Web API integration validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('Web API integration validation failed:\n- ' + errors.join('\n- ')); + } + + approve(); + }); +} - approve(); +runInstrumented('validate-integrate-webapi', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); function findApiClient(projectRoot) { @@ -122,3 +130,12 @@ function findTypeFiles(projectRoot) { return files; } + +if (require.main === module) { + runInstrumented('validate-integrate-webapi', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; diff --git a/plugins/power-pages/skills/setup-auth/scripts/validate-auth.js b/plugins/power-pages/skills/setup-auth/scripts/validate-auth.js index 409bc5e26..4dc3f84e8 100644 --- a/plugins/power-pages/skills/setup-auth/scripts/validate-auth.js +++ b/plugins/power-pages/skills/setup-auth/scripts/validate-auth.js @@ -6,10 +6,12 @@ const fs = require('fs'); const path = require('path'); const { approve, block, runValidation, findProjectRoot } = require('../../../scripts/lib/validation-helpers'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation((cwd) => { - const projectRoot = findProjectRoot(cwd); - if (!projectRoot) approve(); // Not a Power Pages project, skip +async function main() { + return runValidation((cwd) => { + const projectRoot = findProjectRoot(cwd); + if (!projectRoot) approve(); // Not a Power Pages project, skip // Check if any auth files exist — if none, this wasn't an auth session const authServiceExists = findAuthService(projectRoot); @@ -56,11 +58,17 @@ runValidation((cwd) => { errors.push('Missing auth UI component (AuthButton or equivalent)'); } - if (errors.length > 0) { - block('Authentication setup validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('Authentication setup validation failed:\n- ' + errors.join('\n- ')); + } + + approve(); + }); +} - approve(); +runInstrumented('validate-setup-auth', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); function findAuthService(projectRoot) { @@ -125,3 +133,12 @@ function findAuthComponent(projectRoot) { return null; } + +if (require.main === module) { + runInstrumented('validate-setup-auth', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; diff --git a/plugins/power-pages/skills/setup-datamodel/scripts/validate-datamodel.js b/plugins/power-pages/skills/setup-datamodel/scripts/validate-datamodel.js index 0e7f2d78f..9b60f9b85 100644 --- a/plugins/power-pages/skills/setup-datamodel/scripts/validate-datamodel.js +++ b/plugins/power-pages/skills/setup-datamodel/scripts/validate-datamodel.js @@ -8,10 +8,12 @@ const fs = require('fs'); const path = require('path'); const { approve, block, runValidation, findPath, getAuthToken, makeRequest, getEnvironmentUrl } = require('../../../scripts/lib/validation-helpers'); +const { runInstrumented } = require(path.resolve(__dirname, '..', '..', '..', 'scripts', 'lib', 'telemetry-runner')); -runValidation(async (cwd) => { - const manifestPath = findPath(cwd, '.datamodel-manifest.json'); - if (!manifestPath) approve(); // Not a data model session, skip +async function main() { + return runValidation(async (cwd) => { + const manifestPath = findPath(cwd, '.datamodel-manifest.json'); + if (!manifestPath) approve(); // Not a data model session, skip const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); if (!manifest.tables || manifest.tables.length === 0) approve(); @@ -41,11 +43,17 @@ runValidation(async (cwd) => { } } - if (errors.length > 0) { - block('Dataverse data model validation failed:\n- ' + errors.join('\n- ')); - } + if (errors.length > 0) { + block('Dataverse data model validation failed:\n- ' + errors.join('\n- ')); + } + + approve(); + }); +} - approve(); +runInstrumented('validate-setup-datamodel', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); }); async function checkTableExists(envUrl, token, logicalName) { @@ -81,3 +89,12 @@ async function getTableColumns(envUrl, token, logicalName) { return []; } } + +if (require.main === module) { + runInstrumented('validate-setup-datamodel', main).catch((err) => { + process.stderr.write(String((err && err.stack) || err) + '\n'); + process.exit(1); + }); +} + +module.exports = { main }; From 67f74108d3136b220e3a701826e53a728f46a1d9 Mon Sep 17 00:00:00 2001 From: Amit Joshi Date: Wed, 22 Apr 2026 18:59:35 +0530 Subject: [PATCH 32/55] docs(power-pages): document telemetry conventions Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/power-pages/AGENTS.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugins/power-pages/AGENTS.md b/plugins/power-pages/AGENTS.md index d3a4cc088..4783ca18b 100644 --- a/plugins/power-pages/AGENTS.md +++ b/plugins/power-pages/AGENTS.md @@ -78,6 +78,18 @@ These patterns have caused repeated PR review feedback. Check for them before su - **Template placeholders in `