Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

- `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
Expand Down
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
20 changes: 20 additions & 0 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,26 @@ 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 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
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
Expand Down
69 changes: 62 additions & 7 deletions src/app/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -887,6 +893,28 @@ impl AppBuilder {
&self.deployment
}

/// Ship no telemetry on an end-user install: no `telemetry` command
/// 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
/// 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.
///
Expand Down Expand Up @@ -1248,14 +1276,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)
};

Expand Down Expand Up @@ -1352,8 +1387,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
Expand Down Expand Up @@ -1597,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)
{
Expand Down Expand Up @@ -1663,6 +1703,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,
Expand Down Expand Up @@ -1833,6 +1875,12 @@ pub struct App<C: AppContext> {
/// that has not yet read anybody's choice.
#[cfg(feature = "telemetry")]
telemetry_policy: Arc<crate::telemetry::TelemetryPolicy>,
/// 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 `<APP>_TELEMETRY_*` variables and the doctor findings.
Expand Down Expand Up @@ -2339,6 +2387,13 @@ impl<C: AppContext> App<C> {
/// 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
Expand Down
199 changes: 199 additions & 0 deletions tests/unit/end_user_telemetry_opt_out.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// 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<TestCtx> {
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<TestCtx>) -> Vec<String> {
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_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();
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));
}
Loading