Skip to content

Commit 0a9864c

Browse files
amitjoshi438Amit Joshiclaude
authored
Per-plugin telemetry env-var toggle + enable power-pages transmission (#195)
* feat(telemetry): add per-plugin env-var name + value reader Adds `telemetryEnvVarName` and `readTelemetryEnvChoice` to shared/telemetry/lib/user-config.js so automation/CI can toggle telemetry via POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN> without the slash skill. Injectable `env` param keeps tests hermetic. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(telemetry): resolve opt-out by config-wins-then-env precedence Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(telemetry): cover env-var transmission gate + config-wins in dispatcher Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(telemetry): status CLI reports effective state, honoring env override Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(telemetry): isolate per-plugin env var in CLI test harness * docs(telemetry): document per-plugin env-var opt-out + config-wins precedence * telemetry(power-pages): enable transmission (ikey.json disabled:false) Flips the repo-side kill switch off so power-pages 1DS telemetry transmits to the resolved regional collector. Overrides the default-ship-disabled posture per explicit maintainer instruction. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * telemetry: switch env toggle to dotnet-style _OPTOUT with highest precedence Per PR #195 review (priyanshu92): rename the per-plugin env var to POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT (power-pages: POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT). It is a dotnet-style opt-out boolean (1/true disables) and now has the HIGHEST precedence — overriding both a persisted config.json choice and /telemetry on. The env var only disables; it never re-enables. - user-config.js: telemetryOptOutEnvVarName + readTelemetryEnvOptOut replace the on/off env reader; effectiveTelemetryChoice short-circuits to "off" on opt-out. - Tests rewritten for opt-out semantics + env-wins precedence; user-config test harness wraps isTransmissionOptedOut to default env={} so CI vars can't leak (Copilot review). - Docs updated across README/AGENTS/workflow; fixed <plugin> -> <PLUGIN> casing and reconciled the ikey.json disabled posture (now disabled:false) per review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(telemetry): clarify behavior of 'off' command regarding local logs --------- Co-authored-by: Amit Joshi <amitjoshi@microsoft.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ba8497e commit 0a9864c

12 files changed

Lines changed: 238 additions & 17 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ This keeps the skill discoverable in each plugin while preserving install-time p
6969

7070
Edit `shared/telemetry/` directly — the symlink makes changes live for every adopting plugin immediately; there is nothing to re-sync.
7171

72-
Per-plugin iKey/collector routing is pluggable via a `resolver.js` placed next to the plugin's `ikey.json` (implementing the `resolve`/`isProvisioned` contract); the shared library ships only that contract plus a static-key fallback, not any routing logic.
72+
Per-plugin iKey/collector routing is pluggable via a `resolver.js` placed next to the plugin's `ikey.json` (implementing the `resolve`/`isProvisioned` contract); the shared library ships only that contract plus a static-key fallback, not any routing logic. A per-plugin opt-out env var `POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT` (derived as the uppercased plugin name with non-alphanumerics collapsed to `_`, suffixed `_OPTOUT`) disables transmission for automation when set to `1`/`true` (dotnet `*_TELEMETRY_OPTOUT` convention); it has the **highest precedence**, overriding both the persisted `config.json` choice and `/<plugin>:telemetry on`.
7373

7474
Current adopters: `power-pages`. Others adopt on demand.
7575

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,4 @@ Any use of third-party trademarks or logos are subject to those third-party's po
214214

215215
## Telemetry
216216

217-
Plugins that ship 1DS telemetry (currently: `power-pages`) gather anonymous usage signals. Telemetry is default-on; users opt out per-plugin via the `/<plugin>:telemetry off` command (e.g. `/power-pages:telemetry off`), stored in `~/.power-platform-skills/config.json`. See `shared/telemetry/README.md` for what is sent.
217+
Plugins that ship 1DS telemetry (currently: `power-pages`) gather anonymous usage signals. Telemetry is default-on; users opt out per-plugin via the `/<plugin>:telemetry off` command (e.g. `/power-pages:telemetry off`), stored in `~/.power-platform-skills/config.json`. For automation/CI, each adopting plugin also honors a per-plugin opt-out environment variable `POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT` (e.g. `POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1`); when set it disables transmission with the highest precedence, overriding any `/<plugin>:telemetry` choice. See `shared/telemetry/README.md`.

plugins/power-pages/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -407,9 +407,9 @@ These patterns have caused repeated PR review feedback. Check for them before su
407407

408408
This plugin ships 1DS telemetry for skill-run and script-run signals. The shared library lives at the repo-root `shared/telemetry/`; `scripts/lib/telemetry/lib` is a **symlink** to `shared/telemetry/lib`, so the shared code is the live code. Zero npm dependencies — nothing to install.
409409

410-
- **`scripts/lib/telemetry/lib` is a symlink** to the repo-root `shared/telemetry/lib` — edit `shared/telemetry/lib/` directly; there is no copy to re-sync. The real files under `scripts/lib/telemetry/` are `ikey.json` (this plugin's config) and `resolver.js` (the resolver contract implementation). **Posture:** the committed `ikey.json` ships `disabled: true`; a working-tree `disabled: false` is a local experiment only — never commit it.
410+
- **`scripts/lib/telemetry/lib` is a symlink** to the repo-root `shared/telemetry/lib` — edit `shared/telemetry/lib/` directly; there is no copy to re-sync. The real files under `scripts/lib/telemetry/` are `ikey.json` (this plugin's config) and `resolver.js` (the resolver contract implementation). **Posture:** the committed `ikey.json` ships `disabled: false` — transmission is enabled for power-pages (the tenant-side Kusto stream + annotation for `PagesAIPluginEvent` are provisioned). Set `disabled: true` to hard-off (no POST, no local log) if you need to suppress all telemetry at the source.
411411
- **Region routing (Artemis geo + cloud stamp)** lives in `scripts/lib/telemetry/region/` and is wired through `scripts/lib/telemetry/resolver.js`, which implements the resolver contract (`resolve({ event, cfg, cloud, configDir })` / `isProvisioned(cfg)`). The shared dispatcher is routing-agnostic — it auto-discovers `resolver.js` by convention (sibling of `ikey.json`) and calls it; the `region/` implementation is entirely plugin-owned and never referenced by the shared library.
412-
- **Privacy posture:** anonymous telemetry is **default-on**. There is no consent prompt in skills. Users opt out via `/power-pages:telemetry off`, which stores a per-plugin choice in `~/.power-platform-skills/config.json` (`telemetry["power-pages"] = "off"`). Opting out stops transmission only; the local diagnostic mirror is still written. Re-enable with `/power-pages:telemetry on`.
412+
- **Privacy posture:** anonymous telemetry is **default-on**. There is no consent prompt in skills. Users opt out via `/power-pages:telemetry off`, which stores a per-plugin choice in `~/.power-platform-skills/config.json` (`telemetry["power-pages"] = "off"`). Opting out stops transmission only; the local diagnostic mirror is still written. Re-enable with `/power-pages:telemetry on`. Automation/CI can disable transmission by setting `POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1` (or `true`); this opt-out has the **highest precedence** — it overrides both a persisted `/power-pages:telemetry` choice and `/power-pages:telemetry on`. Transmission-only suppression — the local mirror is still written.
413413
- **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 design doc.
414414
- **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.
415415

plugins/power-pages/README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,7 @@ Enables, disables, or checks the status of anonymous usage telemetry. Per-user a
399399
- `/power-pages:telemetry off` — stop sending telemetry (nothing leaves your machine)
400400
- `/power-pages:telemetry on` — resume sending telemetry
401401
- No personal data is ever collected (anonymous: skill name, plugin version, OS, Node version)
402+
- Automation/CI: set `POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1` to disable (highest precedence — overrides any saved choice)
402403

403404
## Agents
404405

@@ -524,8 +525,20 @@ inputs, site names, URLs, credentials, usernames, or hostnames.
524525
When **off**, nothing leaves your machine. A local diagnostic copy of each event
525526
is still written to `~/.power-platform-skills/events.jsonl` so you can see exactly
526527
what would have been sent; delete it anytime. The setting is stored at
527-
`~/.power-platform-skills/config.json` (`{ "telemetry": { "power-pages": "off" } }`),
528-
so CI/headless environments can opt out by writing that file directly.
528+
`~/.power-platform-skills/config.json`
529+
(`{ "telemetry": { "power-pages": "off" } }`).
530+
531+
For automation / CI, set the per-plugin opt-out environment variable instead of
532+
editing that file:
533+
534+
```bash
535+
POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1 # stop sending telemetry
536+
```
537+
538+
Set it to `1` or `true` (dotnet `*_TELEMETRY_OPTOUT` convention). This opt-out has
539+
the **highest precedence** — it overrides a saved `/power-pages:telemetry` choice
540+
and even `/power-pages:telemetry on`. Like `off` from the command, it suppresses
541+
transmission only — the local `events.jsonl` mirror is still written.
529542

530543
## License
531544

plugins/power-pages/scripts/lib/telemetry/ikey.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"event_stream_name": "PagesAIPluginEvent",
3-
"disabled": true,
3+
"disabled": false,
44
"default_region": "us",
55
"regions": {
66
"internal": {

shared/skills/telemetry/telemetry-workflow.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ usage telemetry for this plugin. Default to `status` when no argument is given.
1717

1818
## What to know (for answering follow-ups)
1919

20-
- `off` stops transmission to Microsoft. **Nothing leaves the machine.** A local
21-
diagnostic log is still written at `~/.power-platform-skills/events.jsonl`.
20+
- `off` stops transmission to Microsoft. **Nothing leaves the machine.**.
2221
- `on` re-enables transmission. The choice is **per-user and per-plugin** and
2322
takes effect on the next event (no restart).
2423
- **No personal data is ever collected.** Telemetry is anonymous: it records only
2524
things like skill name, plugin version, OS, and Node version. It never includes
2625
file paths, prompts, tool inputs, site names, URLs, credentials, usernames, or
2726
hostnames.
27+
- **Automation/CI** can disable telemetry by setting the opt-out env var
28+
`POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT` (e.g.
29+
`POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1`) instead of running this
30+
command. Set it to `1` or `true` (the dotnet `*_TELEMETRY_OPTOUT` convention).
31+
`<PLUGIN>` is the plugin name uppercased with non-alphanumerics collapsed to `_`.
32+
This opt-out has the highest precedence — it overrides a saved choice from this
33+
command and even `on`. It suppresses transmission only, like `off`.

shared/telemetry/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ hook (~5ms when disabled, ~3-5s otherwise — incl. when the user opted out)
2222
├─ kill switch (cfg.disabled) → exit ← HARD OFF: no local log, no POST
2323
├─ sanitizeData (FIELD_TYPES allowlist)
2424
├─ appendLocal({time,name,data}) → events.jsonl ← ALWAYS (the mirror)
25-
├─ user opt-out (config.json telemetry[plugin]="off") → exit (mirror written, no POST)
25+
├─ user opt-out (env var POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT=1 OR config.json choice "off"; env wins) → exit (mirror written, no POST)
2626
├─ resolve destination → iKey + collector_url ← resolver.js (plugin) or static key
2727
├─ iKey missing/placeholder → exit (mirror already written, no POST)
2828
├─ build CS4.0 envelope (same time + sanitized data)
@@ -98,6 +98,14 @@ The dispatcher runs a defense-in-depth allowlist filter against `FIELD_TYPES` be
9898

9999
- **Default-on.** Anonymous telemetry is enabled by default. No first-run prompt.
100100
- **Opt out of transmission** via `/<plugin>:telemetry off` (per-user, per-plugin). This writes `telemetry[<plugin>] = "off"` into `~/.power-platform-skills/config.json` and stops the network POST to the collector — **nothing leaves the machine** — but the local diagnostic mirror (`events.jsonl`) is still written so the user/developer can see exactly what would have been sent. It is therefore an opt-out of *transmission*, not of local logging. CI/headless can opt out by writing that file directly. Re-enable with `/<plugin>:telemetry on`.
101+
- **Opt out for automation** via the per-plugin opt-out env var
102+
`POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT` (e.g.
103+
`POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT=1`). Set it to `1` or `true`
104+
(the dotnet `*_TELEMETRY_OPTOUT` convention) to disable transmission; it only
105+
disables, never re-enables. It has the **highest precedence** — it overrides a
106+
persisted `config.json` choice and `/<plugin>:telemetry on`. The dispatcher reads
107+
it inside the transmission gate, after the local mirror is written, so it
108+
suppresses transmission only — the local mirror is still written.
101109
- **Repo-side kill switch (true hard-off).** `ikey.json` carries a `disabled` flag. When `true` (or when `ikey.json` is missing/unreadable), every entry point — hooks, `emit-from-prompt`, and the dispatcher — short-circuits BEFORE any PAC shellout or process spawn, so there is **no POST and no local log**. Ship `true` and flip to `false` only after the tenant-side Kusto stream and annotation are provisioned.
102110

103111
The `disabled` flag is checked at every layer that could perform user-facing work: the pretool/posttool hooks and `emit-from-prompt.js`. A disabled plugin emits zero side effects. The per-plugin user opt-out, by contrast, is enforced inside the detached dispatcher AFTER the local mirror is written — so an opted-out run still produces `events.jsonl` (and incurs the same event-building cost as an enabled run) but never transmits.

shared/telemetry/lib/telemetry-config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
const fs = require("node:fs");
55
const os = require("node:os");
66
const path = require("node:path");
7-
const { readTelemetryChoice, setTelemetryChoice } = require("./user-config");
7+
const { setTelemetryChoice, effectiveTelemetryChoice } = require("./user-config");
88

99
const ANONYMITY =
1010
"ℹ️ No personal data is collected. Telemetry is anonymous — it records only\n" +
@@ -58,7 +58,7 @@ function main() {
5858
const dir = configDir();
5959

6060
if (action === "status") {
61-
const on = readTelemetryChoice(dir, plugin) !== "off"; // default ON
61+
const on = effectiveTelemetryChoice(dir, plugin) !== "off"; // default ON; honors env override when no stored choice
6262
if (on) {
6363
out(`Telemetry (${plugin}): ON`);
6464
out(ANONYMITY);

shared/telemetry/lib/user-config.js

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,40 @@ function readTelemetryChoice(configDir, pluginName) {
3131
return v === "on" || v === "off" ? v : null;
3232
}
3333

34-
function isTransmissionOptedOut(configDir, pluginName) {
35-
return readTelemetryChoice(configDir, pluginName) === "off";
34+
// Builds the per-plugin opt-out var name: POWER_PLATFORM_SKILLS_TELEMETRY_<PLUGIN>_OPTOUT
35+
// where <PLUGIN> is the plugin name uppercased with non-alphanumeric runs -> "_".
36+
function telemetryOptOutEnvVarName(pluginName) {
37+
return (
38+
"POWER_PLATFORM_SKILLS_TELEMETRY_" +
39+
String(pluginName).toUpperCase().replace(/[^A-Z0-9]+/g, "_") +
40+
"_OPTOUT"
41+
);
42+
}
43+
44+
// True when the per-plugin opt-out env var is set to a truthy value (`1` or
45+
// `true`, case-insensitive). Follows the dotnet `*_TELEMETRY_OPTOUT` convention:
46+
// the var only disables — it is never used to re-enable. Unset / empty / `0` /
47+
// `false` / anything else => not opted out. `env` is injectable so tests never
48+
// mutate the real process.env.
49+
function readTelemetryEnvOptOut(pluginName, env = process.env) {
50+
if (!pluginName) return false;
51+
const v = String(env[telemetryOptOutEnvVarName(pluginName)] || "").trim().toLowerCase();
52+
return v === "1" || v === "true";
53+
}
54+
55+
// Resolves the effective on/off choice by precedence (highest first):
56+
// 1. the env-var opt-out (when truthy => "off"; overrides everything below)
57+
// 2. persisted config.json per-plugin choice (set via the slash skill)
58+
// 3. null = default-on
59+
// The env var has the highest precedence and can only force "off"; it never
60+
// re-enables a config opt-out.
61+
function effectiveTelemetryChoice(configDir, pluginName, env = process.env) {
62+
if (readTelemetryEnvOptOut(pluginName, env)) return "off";
63+
return readTelemetryChoice(configDir, pluginName) ?? null;
64+
}
65+
66+
function isTransmissionOptedOut(configDir, pluginName, env = process.env) {
67+
return effectiveTelemetryChoice(configDir, pluginName, env) === "off";
3668
}
3769

3870
// Merge-writes { telemetry: { [pluginName]: choice } }, preserving every other
@@ -60,5 +92,8 @@ module.exports = {
6092
readTelemetryChoice,
6193
setTelemetryChoice,
6294
isTransmissionOptedOut,
95+
telemetryOptOutEnvVarName,
96+
readTelemetryEnvOptOut,
97+
effectiveTelemetryChoice,
6398
CONFIG_FILE_NAME,
6499
};

shared/telemetry/tests/emit-dispatcher.test.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ function runDispatcher({ event, env }) {
5050
POWER_PLATFORM_SKILLS_FAKE_HTTPS: env.fakeProbe || "",
5151
POWER_PLATFORM_SKILLS_IKEY_JSON: ikeyJsonPath,
5252
POWER_PLATFORM_SKILLS_CLOUD: env.cloud || "",
53+
POWER_PLATFORM_SKILLS_TELEMETRY_POWER_PAGES_OPTOUT: env.optOut || "",
5354
},
5455
});
5556
}
@@ -501,3 +502,52 @@ test("dispatcher writes the mirror but does NOT POST when neither resolver nor s
501502
assert.ok(!fs.existsSync(probePath), "no key resolved → no POST");
502503
assert.ok(fs.existsSync(path.join(tmp, "events.jsonl")), "local mirror still written");
503504
});
505+
506+
test("env opt-out (no config choice) suppresses the POST but still writes the mirror", () => {
507+
const tmp = mkTmp();
508+
const probePath = path.join(tmp, "probe.json");
509+
const { status } = runDispatcher({
510+
event: fakeEvent,
511+
env: {
512+
configDir: tmp,
513+
iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa",
514+
collectorUrl: "https://example.invalid/OneCollector/1.0/",
515+
fakeProbe: probePath,
516+
optOut: "1",
517+
},
518+
});
519+
assert.equal(status, 0);
520+
assert.ok(!fs.existsSync(probePath), "env opt-out must skip the POST");
521+
assert.ok(
522+
fs.existsSync(path.join(tmp, "events.jsonl")),
523+
"env opt-out must still write the local mirror"
524+
);
525+
});
526+
527+
test("env opt-out overrides a persisted 'on' choice (env wins) — no POST", () => {
528+
const tmp = mkTmp();
529+
const probePath = path.join(tmp, "probe.json");
530+
fs.writeFileSync(
531+
path.join(tmp, "config.json"),
532+
JSON.stringify({ telemetry: { "power-pages": "on" } })
533+
);
534+
const { status } = runDispatcher({
535+
event: fakeEvent,
536+
env: {
537+
configDir: tmp,
538+
iKey: "real-ikey-32-chars-minimum-aaaaaaaaaaaaaa",
539+
collectorUrl: "https://example.invalid/OneCollector/1.0/",
540+
fakeProbe: probePath,
541+
optOut: "1",
542+
},
543+
});
544+
assert.equal(status, 0);
545+
assert.ok(
546+
!fs.existsSync(probePath),
547+
"env opt-out has highest precedence → must skip the POST even with config 'on'"
548+
);
549+
assert.ok(
550+
fs.existsSync(path.join(tmp, "events.jsonl")),
551+
"opt-out suppresses transmission only — the local mirror is still written"
552+
);
553+
});

0 commit comments

Comments
 (0)