From e58202494cc11b7f9b10704b8eacc87baf9a3526 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5" Date: Sat, 17 Oct 2026 11:40:26 +0000 Subject: [PATCH 1/2] feat(telemetry): add AppBuilder::without_end_user_telemetry An end-user install of an app that opts out ships no telemetry: no `telemetry` command group, no telemetry doctor checks, no first-run notice and no export. Startup never runs, so stored consent is not read either. A Service process is unaffected. For a binary that is a service where an operator runs it and a program a person runs themselves, picking its Deployment per process. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + Cargo.toml | 5 + docs/telemetry.md | 19 +++ src/app/builder.rs | 63 +++++++- tests/unit/end_user_telemetry_opt_out.rs | 175 +++++++++++++++++++++++ 5 files changed, 264 insertions(+), 5 deletions(-) create mode 100644 tests/unit/end_user_telemetry_opt_out.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 856948b..84cc7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ ### Added +- `AppBuilder::without_end_user_telemetry()` (feature `telemetry`): an + end-user install ships no telemetry — no `telemetry` command group, no + telemetry doctor checks, no first-run notice, no export, and stored consent + is not read. For a binary that runs as a service where an operator runs it + and as a program a person runs themselves, and picks its `Deployment` per + process. A `Deployment::Service` process is unaffected. + - `cli-framework-oidc` gains a `host-session` feature: a server-side sign-in for a web host that keeps the end user's tokens out of the browser. A confidential client signs in with PKCE, `state` and `nonce`; the tokens live diff --git a/Cargo.toml b/Cargo.toml index 12a1a6f..a5d8494 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1076,6 +1076,11 @@ name = "unit_testkit_telemetry_knobs" path = "tests/unit/testkit_telemetry_knobs.rs" required-features = ["telemetry", "config", "testkit"] +[[test]] +name = "unit_end_user_telemetry_opt_out" +path = "tests/unit/end_user_telemetry_opt_out.rs" +required-features = ["telemetry", "config", "testkit"] + # Self-install operations over a temporary InstallEnv (ADR 0080). [[test]] name = "unit_self_install" diff --git a/docs/telemetry.md b/docs/telemetry.md index 47030ae..f95185d 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -274,6 +274,25 @@ installation but never raise it above what the person chose. `Deployment::Service` is the fleet case; a standalone `ApiServerBuilder` defaults to it. +A binary that is both — a service where an operator runs it, and a program a +person installs and runs themselves — picks its deployment per process. +`Deployment::Service` on the server keeps its telemetry; on the person's +machine, `EndUser` plus `without_end_user_telemetry()` ships none: no +`telemetry` command group, no telemetry doctor checks, no notice, no export, +and stored consent from an earlier version is not read. The end-user +commands, such as `self`, stay. + +```rust +let builder = AppBuilder::new() + .with_version("demo", "0.1.0") + .with_deployment(if std::env::var_os("DEMO_INSTANCE").is_some() { + Deployment::Service + } else { + Deployment::EndUser { privacy_url: None } + }) + .without_end_user_telemetry(); +``` + ### Where it sends, and how much ```rust diff --git a/src/app/builder.rs b/src/app/builder.rs index c02a6b6..9659830 100644 --- a/src/app/builder.rs +++ b/src/app/builder.rs @@ -102,6 +102,10 @@ pub struct AppBuilder { /// becomes a build error. #[cfg(feature = "telemetry")] telemetry_ops: Vec<&'static [crate::telemetry::ProbeSpec]>, + /// Whether an end-user install gets telemetry at all; see + /// [`Self::without_end_user_telemetry`]. A service ignores it. + #[cfg(feature = "telemetry")] + end_user_telemetry: bool, /// How the app answers "who is calling?"; see /// [`crate::telemetry::IdentityResolver`]. #[cfg(feature = "telemetry")] @@ -200,6 +204,8 @@ impl AppBuilder { #[cfg(feature = "telemetry")] telemetry_ops: Vec::new(), #[cfg(feature = "telemetry")] + end_user_telemetry: true, + #[cfg(feature = "telemetry")] telemetry_identity: None, #[cfg(feature = "telemetry")] telemetry_attrs: Vec::new(), @@ -887,6 +893,27 @@ impl AppBuilder { &self.deployment } + /// Ship no telemetry on an end-user install: no `telemetry` command + /// group, no telemetry doctor checks, no first-run notice and no export. + /// + /// For a binary that is both: a service where an operator runs it, and a + /// program a person installs and runs themselves (with `self` and the + /// other end-user commands). Such an app picks its [`Deployment`] per + /// process -- `Service` on the server, `EndUser` on the person's machine + /// -- and wants telemetry only from the server. Without this, the + /// end-user process tells the person usage statistics are off and how to + /// turn them on, for an app whose author never meant to collect any. + /// + /// A `Service` process is unaffected: its telemetry stays the operator's + /// decision, through the `OTEL_*` variables and the kill switches. + /// + /// [`Deployment`]: crate::telemetry::Deployment + #[cfg(feature = "telemetry")] + pub fn without_end_user_telemetry(mut self) -> Self { + self.end_user_telemetry = false; + self + } + /// Build this app against a bare [`TestContext`], returning the build /// error rather than panicking. /// @@ -1248,14 +1275,21 @@ impl AppBuilder { // `feature = "telemetry"` alone, not `all(telemetry, doctor)`: // `telemetry` lists `doctor` among its own features (Cargo.toml), so // the two cannot come apart. + // + // An end-user install the author opted out of telemetry for gets + // none of the six: they would describe a pipeline it never starts. + #[cfg(feature = "telemetry")] + let telemetry_withdrawn = !self.end_user_telemetry && self.deployment.is_end_user(); #[cfg(feature = "telemetry")] let (telemetry_policy_cell, telemetry_report_cell) = { let policy_cell = crate::telemetry::StartupCell::filled(telemetry_policy.clone()); let report_cell = crate::telemetry::StartupCell::empty(); - self.push_doctor_checks(crate::telemetry::telemetry_checks( - policy_cell.clone(), - report_cell.clone(), - )); + if !telemetry_withdrawn { + self.push_doctor_checks(crate::telemetry::telemetry_checks( + policy_cell.clone(), + report_cell.clone(), + )); + } (policy_cell, report_cell) }; @@ -1352,8 +1386,12 @@ impl AppBuilder { // made; a runtime toggle command there would be a second source of // truth that `telemetry status` on one replica could not answer for // the fleet, so no group is registered for `Deployment::Service`. + // + // Nor on an end-user install the author opted out of telemetry for + // ([`Self::without_end_user_telemetry`]): there is nothing to see or + // change. #[cfg(feature = "telemetry")] - if self.deployment.is_end_user() { + if self.deployment.is_end_user() && !telemetry_withdrawn { // `CommandRegistry::get` reads only `tree_commands`, but // `register_telemetry_commands` registers a *group*, and // `register_group` collides on either map. An app whose own @@ -1663,6 +1701,8 @@ impl AppBuilder { #[cfg(feature = "telemetry")] telemetry_policy, #[cfg(feature = "telemetry")] + telemetry_withdrawn, + #[cfg(feature = "telemetry")] startup_report: None, #[cfg(feature = "telemetry")] telemetry_policy_cell, @@ -1833,6 +1873,12 @@ pub struct App { /// that has not yet read anybody's choice. #[cfg(feature = "telemetry")] telemetry_policy: Arc, + /// An end-user install the author opted out of telemetry for + /// ([`AppBuilder::without_end_user_telemetry`]). Startup never runs, so + /// `telemetry_policy` keeps the build-time resolution, which is `Off` + /// for an end user. + #[cfg(feature = "telemetry")] + telemetry_withdrawn: bool, /// What the spec 025 startup sequence *observed*, as opposed to what it /// decided — the subscriber outcome, the store state, the kill switch, /// the unmatched `_TELEMETRY_*` variables and the doctor findings. @@ -2339,6 +2385,13 @@ impl App { /// CLI process still delivers its spans. #[cfg(feature = "telemetry")] fn init_telemetry(&mut self) -> crate::telemetry::TelemetryGuard { + // Opted out: no store, no notice, no export. The guard still exists + // because `run_with_args` holds one unconditionally. + if self.telemetry_withdrawn { + let provider = opentelemetry_sdk::trace::SdkTracerProvider::builder().build(); + return crate::telemetry::TelemetryGuard::new(provider, None); + } + // A kill switch wins over every layer, and a shim that predates // the switches is still a layer. `OTEL_SDK_DISABLED` already stopped // the shim, through `TelemetryConfig::is_active`; the other two are diff --git a/tests/unit/end_user_telemetry_opt_out.rs b/tests/unit/end_user_telemetry_opt_out.rs new file mode 100644 index 0000000..ce75669 --- /dev/null +++ b/tests/unit/end_user_telemetry_opt_out.rs @@ -0,0 +1,175 @@ +// tests/unit/end_user_telemetry_opt_out.rs +//! `AppBuilder::without_end_user_telemetry`: an end-user install the author +//! opted out of telemetry for gets no `telemetry` command group, no telemetry +//! doctor checks, no first-run notice and no export, while a `Service` +//! process of the same app keeps its telemetry. +//! +//! The use is a binary that is both a service and a program a person runs: +//! it picks its `Deployment` per process and wants telemetry only from the +//! service. Every test here has a control -- the same app without the opt-out +//! -- so a test cannot pass because the thing it looks for was never there. + +use cli_framework::app::{AppBuilder, AppContext}; +use cli_framework::doctor::builtin::TmpdirWritableCheck; +use cli_framework::telemetry::{Deployment, TelemetryLevel}; +use cli_framework::testkit::CliTestHarness; +use std::path::Path; +use std::sync::Arc; + +struct TestCtx; +impl AppContext for TestCtx {} + +/// The first line of the first-run notice; see `testkit_telemetry_knobs.rs` +/// for why the sentence and not the word "telemetry". +const NOTICE_LINE: &str = "usage statistics are off"; + +fn builder(dir: &Path, deployment: Deployment, opt_out: bool) -> AppBuilder { + // One app check, so `doctor` exists whatever happens to the six + // telemetry checks: an app with none at all gets no `doctor` command. + let b = AppBuilder::new() + .with_version("demo", "0.0.0") + .with_deployment(deployment) + .with_telemetry_config_dir(dir) + .register_doctor_checks(vec![Arc::new(TmpdirWritableCheck)]); + if opt_out { + b.without_end_user_telemetry() + } else { + b + } +} + +fn end_user() -> Deployment { + Deployment::EndUser { privacy_url: None } +} + +fn harness(dir: &Path, deployment: Deployment, opt_out: bool) -> CliTestHarness { + CliTestHarness::new(builder(dir, deployment, opt_out).build(TestCtx).unwrap()) +} + +fn has_telemetry_group(dir: &Path, deployment: Deployment, opt_out: bool) -> bool { + let app = builder(dir, deployment, opt_out).build(TestCtx).unwrap(); + let registry = app.command_registry(); + registry.get("telemetry").is_some() || registry.group_metadata_for("telemetry").is_some() +} + +async fn doctor_check_ids(h: &mut CliTestHarness) -> Vec { + let out = h.run(&["demo", "doctor", "--json"]).await; + let report: serde_json::Value = serde_json::from_str(&out.stdout) + .unwrap_or_else(|e| panic!("doctor --json is not JSON: {e}\n{}", out.stdout)); + report["findings"] + .as_array() + .expect("findings array") + .iter() + .map(|f| f["check_id"].as_str().unwrap_or_default().to_string()) + .collect() +} + +#[tokio::test] +async fn an_opted_out_end_user_sees_no_first_run_notice() { + let dir = tempfile::tempdir().unwrap(); + + let mut control = harness(dir.path(), end_user(), false).with_interactive_stderr(true); + let out = control.run(&["demo", "--help"]).await; + assert!( + out.stderr.contains(NOTICE_LINE), + "control: an end user on a terminal sees the notice: {:?}", + out.stderr + ); + + let dir = tempfile::tempdir().unwrap(); + let mut h = harness(dir.path(), end_user(), true).with_interactive_stderr(true); + let out = h.run(&["demo", "--help"]).await; + assert!( + !out.stderr.contains(NOTICE_LINE), + "an app that collects nothing must not tell the person how to turn \ + collection on: {:?}", + out.stderr + ); +} + +#[test] +fn an_opted_out_end_user_has_no_telemetry_command_group() { + let dir = tempfile::tempdir().unwrap(); + assert!( + has_telemetry_group(dir.path(), end_user(), false), + "control: an end-user app gets the telemetry group" + ); + assert!( + !has_telemetry_group(dir.path(), end_user(), true), + "an opted-out end-user app has nothing to see or change" + ); +} + +#[tokio::test] +async fn an_opted_out_end_user_has_no_telemetry_doctor_checks() { + let dir = tempfile::tempdir().unwrap(); + let control = doctor_check_ids(&mut harness(dir.path(), end_user(), false)).await; + assert!( + control.iter().any(|id| id.starts_with("telemetry.")), + "control: an end-user app gets the six telemetry checks: {control:?}" + ); + + let ids = doctor_check_ids(&mut harness(dir.path(), end_user(), true)).await; + assert!( + ids.iter().any(|id| id == "tmpdir-writable"), + "the app's own checks still run: {ids:?}" + ); + assert!( + !ids.iter().any(|id| id.starts_with("telemetry.")), + "no telemetry checks for a pipeline that never starts: {ids:?}" + ); +} + +#[tokio::test] +async fn stored_consent_does_not_turn_an_opted_out_end_user_back_on() { + // A person who said yes before the author opted out, or a settings file + // copied from another app: the opt-out still wins, because startup -- + // the only step that reads the store -- never runs. + let dir = tempfile::tempdir().unwrap(); + let mut before = harness(dir.path(), end_user(), false); + let out = before.run(&["demo", "telemetry", "set", "usage"]).await; + assert_eq!(out.exit_code, 0, "telemetry set failed: {}", out.stderr); + + let mut control = builder(dir.path(), end_user(), false) + .build(TestCtx) + .unwrap(); + control + .run_with_args(vec!["demo".into(), "--version".into()]) + .await + .unwrap(); + assert_eq!( + control.telemetry_policy().level, + TelemetryLevel::Usage, + "control: stored consent turns an end-user app on" + ); + + let mut app = builder(dir.path(), end_user(), true) + .build(TestCtx) + .unwrap(); + app.run_with_args(vec!["demo".into(), "--version".into()]) + .await + .unwrap(); + assert_eq!(app.telemetry_policy().level, TelemetryLevel::Off); +} + +#[tokio::test] +async fn a_service_keeps_its_telemetry_under_the_opt_out() { + let dir = tempfile::tempdir().unwrap(); + let ids = doctor_check_ids(&mut harness(dir.path(), Deployment::Service, true)).await; + for id in [ + "telemetry.subscriber", + "telemetry.store", + "telemetry.endpoint", + "telemetry.policy", + "telemetry.identity", + "telemetry.env", + ] { + assert!( + ids.iter().any(|r| r == id), + "a service's telemetry is the operator's decision, not the \ + opt-out's: {id} missing from {ids:?}" + ); + } + // A service never gets the group, opted out or not. + assert!(!has_telemetry_group(dir.path(), Deployment::Service, true)); +} From 8d064c39748b4e5270201cfa6cbd23a3e8398973 Mon Sep 17 00:00:00 2001 From: "Claude Opus 5.5" Date: Sat, 17 Oct 2026 11:45:09 +0000 Subject: [PATCH 2/2] feat(telemetry): list no telemetry variables in help when opted out An opted-out end user reads none of the framework's telemetry variables, so root help no longer documents them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- docs/telemetry.md | 5 +++-- src/app/builder.rs | 8 +++++--- tests/unit/end_user_telemetry_opt_out.rs | 24 ++++++++++++++++++++++++ 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84cc7cf..9573994 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,8 +36,8 @@ - `AppBuilder::without_end_user_telemetry()` (feature `telemetry`): an end-user install ships no telemetry — no `telemetry` command group, no - telemetry doctor checks, no first-run notice, no export, and stored consent - is not read. For a binary that runs as a service where an operator runs it + telemetry doctor checks, no telemetry variables in root help, no first-run + notice, no export, and stored consent is not read. For a binary that runs as a service where an operator runs it and as a program a person runs themselves, and picks its `Deployment` per process. A `Deployment::Service` process is unaffected. diff --git a/docs/telemetry.md b/docs/telemetry.md index f95185d..9b6bd59 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -278,8 +278,9 @@ A binary that is both — a service where an operator runs it, and a program a person installs and runs themselves — picks its deployment per process. `Deployment::Service` on the server keeps its telemetry; on the person's machine, `EndUser` plus `without_end_user_telemetry()` ships none: no -`telemetry` command group, no telemetry doctor checks, no notice, no export, -and stored consent from an earlier version is not read. The end-user +`telemetry` command group, no telemetry doctor checks, no telemetry variables +in root help, no notice, no export, and stored consent from an earlier version +is not read. The end-user commands, such as `self`, stay. ```rust diff --git a/src/app/builder.rs b/src/app/builder.rs index 9659830..4df117a 100644 --- a/src/app/builder.rs +++ b/src/app/builder.rs @@ -894,7 +894,8 @@ impl AppBuilder { } /// Ship no telemetry on an end-user install: no `telemetry` command - /// group, no telemetry doctor checks, no first-run notice and no export. + /// group, no telemetry doctor checks, no telemetry variables in root + /// help, no first-run notice and no export. /// /// For a binary that is both: a service where an operator runs it, and a /// program a person installs and runs themselves (with `self` and the @@ -1635,9 +1636,10 @@ impl AppBuilder { // help beside the application's. Registered *after* the app's and the // commands' so that an app which documents one of these names itself // keeps its own wording. The same name the startup scan uses, so the - // variables listed are the ones that will be read. + // variables listed are the ones that will be read. An opted-out end + // user reads none of them, so none are listed. #[cfg(feature = "telemetry")] - { + if !telemetry_withdrawn { let app = self.meta.as_ref().map(|m| m.name).unwrap_or(self.app_name); for (name, description) in crate::telemetry::env::help_entries(app, &published_manifest) { diff --git a/tests/unit/end_user_telemetry_opt_out.rs b/tests/unit/end_user_telemetry_opt_out.rs index ce75669..207000c 100644 --- a/tests/unit/end_user_telemetry_opt_out.rs +++ b/tests/unit/end_user_telemetry_opt_out.rs @@ -100,6 +100,30 @@ fn an_opted_out_end_user_has_no_telemetry_command_group() { ); } +#[tokio::test] +async fn an_opted_out_end_user_lists_no_telemetry_variables_in_help() { + let dir = tempfile::tempdir().unwrap(); + let out = harness(dir.path(), end_user(), false) + .run(&["demo", "--help"]) + .await; + assert!( + out.stdout.contains("DEMO_TELEMETRY_LEVEL"), + "control: root help lists the telemetry variables:\n{}", + out.stdout + ); + + let out = harness(dir.path(), end_user(), true) + .run(&["demo", "--help"]) + .await; + for name in ["DEMO_TELEMETRY_", "OTEL_", "DO_NOT_TRACK"] { + assert!( + !out.stdout.contains(name), + "help documents a variable nothing reads ({name}):\n{}", + out.stdout + ); + } +} + #[tokio::test] async fn an_opted_out_end_user_has_no_telemetry_doctor_checks() { let dir = tempfile::tempdir().unwrap();