From dbcd4f43374c1b7ff9ac13c13c7cd01b76f763b8 Mon Sep 17 00:00:00 2001 From: davidmoshal Date: Sat, 8 Aug 2026 09:36:59 -0700 Subject: [PATCH] fix(cli): make telemetry opt-in by default --- README.md | 8 +- Wiki/changelog/cli.md | 19 +++++ Wiki/features/commands/telemetry.md | 11 +-- Wiki/features/index.md | 2 +- Wiki/features/installation.md | 5 +- Wiki/features/telemetry.md | 21 +++-- .../cmd/openknowledge/telemetry_command.go | 11 +-- .../openknowledge/telemetry_command_test.go | 20 ++++- packages/cli/internal/telemetry/telemetry.go | 31 +++++-- .../cli/internal/telemetry/telemetry_test.go | 83 +++++++++++++++++-- packages/npm/README.md | 8 +- 11 files changed, 174 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 76ab9e1..cccc026 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,10 @@ platforms and verification details. -Before the CLI sends its first event, it discloses anonymous usage and -sanitized error telemetry. Telemetry is enabled by default. Run -`okn telemetry show-payload` to inspect a sample. Put `--no-telemetry` before a -command to disable telemetry and save the opt-out. See +Anonymous usage and sanitized error telemetry is disabled by default. Run +`okn telemetry show-payload` to inspect a sample. Run `okn telemetry enable` +to opt in. Run `okn telemetry disable`, or put `--no-telemetry` before a +command, to disable it. See [Product Telemetry and Privacy](Wiki/features/telemetry.md). ## Workflows diff --git a/Wiki/changelog/cli.md b/Wiki/changelog/cli.md index c203e53..7ca986d 100644 --- a/Wiki/changelog/cli.md +++ b/Wiki/changelog/cli.md @@ -20,6 +20,25 @@ page records release-level changes. - Source: `package.json`, `packages/web/package.json`, `pnpm-lock.yaml`. - Docs: `Wiki/changelog/cli.md`. +### Telemetry + +- CLI product telemetry is now opt-in. The CLI sends no event and creates no + installation ID before you run `okn telemetry enable`. +- Unconfigured `okn telemetry status` reports `disabled` and `default`. +- The schema version 2 migration disables all schema version 1 configurations. + It deletes each random installation ID and all activity state. +- v0.11.0 did not distinguish automatic enablement from explicit opt-in. If you + enabled telemetry in v0.11.0, run `okn telemetry enable` again. +- `okn telemetry disable`, `--no-telemetry`, `DO_NOT_TRACK`, and + `OPENKNOWLEDGE_TELEMETRY=off` remain unchanged. +- Installer preflight and continuous integration suppression remain unchanged. +- Website consent and aggregate `/install` redirect telemetry remain unchanged. +- Source: `packages/cli/internal/telemetry/`, + `packages/cli/cmd/openknowledge/telemetry_command.go`. +- Docs: `README.md`, `packages/npm/README.md`, + `Wiki/features/commands/telemetry.md`, `Wiki/features/telemetry.md`, + `Wiki/features/installation.md`, and `Wiki/features/index.md`. + ## v0.11.0 — 2026-08-08 Version 0.11 adds privacy-safe product telemetry, standalone skill setup, a diff --git a/Wiki/features/commands/telemetry.md b/Wiki/features/commands/telemetry.md index 36d5524..7190719 100644 --- a/Wiki/features/commands/telemetry.md +++ b/Wiki/features/commands/telemetry.md @@ -20,13 +20,14 @@ okn telemetry enable okn --no-telemetry ``` -Telemetry is enabled by default. The CLI prints a disclosure before it sends -the first event. Installer preflight and continuous integration do not send -telemetry. +Telemetry is disabled by default. The CLI sends no event and creates no +installation ID before you run `okn telemetry enable`. Installer preflight and +continuous integration do not send telemetry. `status` reports the saved or default preference. Process-level environment -overrides do not change this report. `show-payload` prints a representative -JSON envelope without sending it. +overrides do not change this report. On an unconfigured installation, `status` +reports `disabled` and `default`. `show-payload` prints a representative JSON +envelope without sending it. `disable` deletes the random installation ID and clears activity markers. `enable` creates a new random installation ID when necessary. diff --git a/Wiki/features/index.md b/Wiki/features/index.md index 83b0eef..1c53134 100644 --- a/Wiki/features/index.md +++ b/Wiki/features/index.md @@ -7,7 +7,7 @@ - [Installation](installation.md) — Gives CLI installation and verification steps. - [Product telemetry and privacy](telemetry.md) — Describes collected data and - opt-out controls. + opt-in controls. - [Configuration](configuration.md) — Describes the `.openknowledge.toml` configuration. - [Commands](commands/) — Lists the current CLI commands. diff --git a/Wiki/features/installation.md b/Wiki/features/installation.md index e7bb9a4..200a63b 100644 --- a/Wiki/features/installation.md +++ b/Wiki/features/installation.md @@ -77,9 +77,8 @@ assets exist. The package versions must also match. Offline transactional tests cover shell and npm installation. The root `pnpm test` gate runs these tests. -Before the CLI sends its first event, it prints the product telemetry -disclosure. Telemetry is enabled by default. Use -`okn --no-telemetry ` to disable it before the first event. See +CLI telemetry is disabled by default. The CLI sends no event and creates no +installation ID before you run `okn telemetry enable`. See [Product Telemetry and Privacy](telemetry.md). --- diff --git a/Wiki/features/telemetry.md b/Wiki/features/telemetry.md index 4bd19e2..6e7e1d2 100644 --- a/Wiki/features/telemetry.md +++ b/Wiki/features/telemetry.md @@ -1,20 +1,20 @@ --- type: Feature Documentation title: Product Telemetry and Privacy -description: Describes anonymous CLI and website telemetry, data limits, and opt-out controls. +description: Describes anonymous CLI and website telemetry, data limits, and consent controls. tags: [openknowledge, telemetry, analytics, privacy] timestamp: 2026-08-08T00:00:00Z --- # Product Telemetry and Privacy -Open Knowledge collects bounded product telemetry to measure installation, +Open Knowledge can collect bounded product telemetry to measure installation, activation, useful command activity, daily activity, and sanitized errors. -CLI telemetry is enabled by default. The CLI prints a disclosure before it -sends the first event. Telemetry commands do not send events. JSON error mode -waits for a prior telemetry disclosure. Installer preflight and continuous -integration do not send telemetry. +CLI telemetry is disabled by default. The CLI sends no event and creates no +installation ID before you run `okn telemetry enable`. Telemetry commands do +not send events. Installer preflight and continuous integration do not send +telemetry. Website analytics require explicit consent. The website records no analytics event before consent. @@ -32,6 +32,10 @@ okn telemetry enable okn --no-telemetry ``` +On an unconfigured installation, `status` reports `disabled` and `default`. +Run `okn telemetry enable` to create a random installation ID and enable CLI +telemetry. + Put `--no-telemetry` before the command. This option disables telemetry for the current command and future commands. It also deletes the random installation ID. @@ -78,8 +82,9 @@ not add request IP addresses or raw user agents to events or upstream requests. ## Delivery -The CLI sends one bounded JSON envelope after a command finishes. Delivery uses -a short timeout. A delivery failure does not change output or exit status. +When enabled, the CLI sends one bounded JSON envelope after a command finishes. +Delivery uses a short timeout. A delivery failure does not change output or +exit status. The first-party relay accepts only documented fields and values. It rejects extra content. The relay converts accepted envelopes to PostHog's batch capture diff --git a/packages/cli/cmd/openknowledge/telemetry_command.go b/packages/cli/cmd/openknowledge/telemetry_command.go index c530505..b802028 100644 --- a/packages/cli/cmd/openknowledge/telemetry_command.go +++ b/packages/cli/cmd/openknowledge/telemetry_command.go @@ -81,10 +81,11 @@ Usage: openknowledge telemetry disable openknowledge telemetry show-payload -Telemetry is enabled by default after a first-run disclosure. It sends only -allowlisted command, outcome, duration, version, platform, and random -installation identifiers. It does not send command arguments, paths, content, -repository or user identity, output, hostnames, IP addresses, or raw user -agents. Use --no-telemetry before a command to disable telemetry persistently. +Telemetry is disabled by default. Enable it with "openknowledge telemetry +enable". When enabled, it sends only allowlisted command, outcome, duration, +version, platform, and random installation identifiers. It does not send +command arguments, paths, content, repository or user identity, output, +hostnames, IP addresses, or raw user agents. Use --no-telemetry before a +command to disable telemetry persistently. ` } diff --git a/packages/cli/cmd/openknowledge/telemetry_command_test.go b/packages/cli/cmd/openknowledge/telemetry_command_test.go index c821049..e2d1f44 100644 --- a/packages/cli/cmd/openknowledge/telemetry_command_test.go +++ b/packages/cli/cmd/openknowledge/telemetry_command_test.go @@ -37,9 +37,17 @@ func TestTelemetryCommandsExposeStateAndPayload(t *testing.T) { stdout, stderr, code := captureMainOutput(t, func() int { return runTelemetry([]string{"status"}) }) - if code != 0 || stderr != "" || !strings.Contains(stdout, "Telemetry: enabled") || !strings.Contains(stdout, "Configuration: default") { + if code != 0 || stderr != "" || !strings.Contains(stdout, "Telemetry: disabled") || !strings.Contains(stdout, "Configuration: default") { t.Fatalf("unexpected default status: code=%d stdout=%q stderr=%q", code, stdout, stderr) } + stdout, stderr, code = captureMainOutput(t, func() int { return runTelemetry([]string{"enable"}) }) + if code != 0 || stderr != "" || !strings.Contains(stdout, "telemetry is enabled") { + t.Fatalf("unexpected enable result: code=%d stdout=%q stderr=%q", code, stdout, stderr) + } + stdout, _, _ = captureMainOutput(t, func() int { return runTelemetry([]string{"status"}) }) + if !strings.Contains(stdout, "Telemetry: enabled") || !strings.Contains(stdout, "Configuration: saved") { + t.Fatalf("unexpected enabled status: %q", stdout) + } _, _, code = captureMainOutput(t, func() int { return runTelemetry([]string{"disable"}) }) if code != 0 { t.Fatalf("disable returned %d", code) @@ -57,3 +65,13 @@ func TestTelemetryCommandsExposeStateAndPayload(t *testing.T) { t.Fatalf("invalid sample payload: %v %q", err, stdout) } } + +func TestTelemetryHelpDescribesExplicitOptIn(t *testing.T) { + help := telemetryHelpText() + if !strings.Contains(help, "disabled by default") || !strings.Contains(help, "telemetry enable") { + t.Fatalf("telemetry help does not describe explicit opt-in: %q", help) + } + if strings.Contains(help, "enabled by default") { + t.Fatalf("telemetry help still describes an enabled default: %q", help) + } +} diff --git a/packages/cli/internal/telemetry/telemetry.go b/packages/cli/internal/telemetry/telemetry.go index 028bc85..be822ed 100644 --- a/packages/cli/internal/telemetry/telemetry.go +++ b/packages/cli/internal/telemetry/telemetry.go @@ -26,8 +26,9 @@ const ( ControlEnv = "OPENKNOWLEDGE_TELEMETRY" SuppressEnv = "OPENKNOWLEDGE_TELEMETRY_SUPPRESS" - defaultEndpoint = "https://openknowledge.sh/api/telemetry" - maxConfigBytes = 64 << 10 + currentConfigSchemaVersion = "2" + defaultEndpoint = "https://openknowledge.sh/api/telemetry" + maxConfigBytes = 64 << 10 ) var now = time.Now @@ -248,6 +249,25 @@ func Load() (Config, bool, error) { if err != nil { return Config{}, false, err } + config, exists, err := readConfig(path) + if err != nil || !exists { + return config, exists, err + } + switch config.SchemaVersion { + case "1": + // Version 1 cannot distinguish default enablement from explicit consent. + config = defaultConfig() + if err := save(path, config); err != nil { + return Config{}, false, err + } + case currentConfigSchemaVersion: + default: + return Config{}, false, fmt.Errorf("unsupported telemetry config version") + } + return config, true, nil +} + +func readConfig(path string) (Config, bool, error) { file, err := os.Open(path) if os.IsNotExist(err) { return Config{}, false, nil @@ -269,9 +289,6 @@ func Load() (Config, bool, error) { if err := decoder.Decode(&config); err != nil { return Config{}, false, err } - if config.SchemaVersion != "1" { - return Config{}, false, fmt.Errorf("unsupported telemetry config version") - } return config, true, nil } @@ -326,7 +343,7 @@ func SamplePayload() Envelope { } func disclosureText() string { - return `Open Knowledge sends anonymous usage and sanitized error telemetry by default. + return `Open Knowledge sends anonymous usage and sanitized error telemetry because it is enabled. It sends command names, outcomes, timing buckets, version, OS, architecture, and a random installation ID. It never sends arguments, paths, content, repository or user identity, command output, hostnames, IP addresses, or raw @@ -335,7 +352,7 @@ user agents. Disable it with "okn --no-telemetry " or } func defaultConfig() Config { - return Config{SchemaVersion: "1", Enabled: true} + return Config{SchemaVersion: currentConfigSchemaVersion, Enabled: false} } func save(path string, config Config) (resultErr error) { diff --git a/packages/cli/internal/telemetry/telemetry_test.go b/packages/cli/internal/telemetry/telemetry_test.go index e64d175..0507388 100644 --- a/packages/cli/internal/telemetry/telemetry_test.go +++ b/packages/cli/internal/telemetry/telemetry_test.go @@ -23,7 +23,75 @@ func noContentResponse() *http.Response { return &http.Response{StatusCode: http.StatusNoContent, Body: io.NopCloser(strings.NewReader(""))} } -func TestFirstRunDisclosesAndSendsAllowlistedEvents(t *testing.T) { +func TestFirstRunDefaultsToDisabledAndSendsNothing(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "telemetry.json") + t.Setenv(ConfigFileEnv, configPath) + t.Setenv(SuppressEnv, "") + t.Setenv("CI", "") + t.Setenv(ControlEnv, "") + t.Setenv("DO_NOT_TRACK", "") + + requests := 0 + client := testClient(func(request *http.Request) (*http.Response, error) { + requests++ + _, _ = io.Copy(io.Discard, request.Body) + return noContentResponse(), nil + }) + t.Setenv(EndpointEnv, "https://telemetry.example.test/v1/events") + + session := Start(StartOptions{Version: "1.2.3", Command: "validate", HTTPClient: client}) + session.Finish(0) + + if requests != 0 { + t.Fatalf("default telemetry sent %d requests", requests) + } + config, exists, err := Status() + if err != nil || exists || config.Enabled || config.InstallationID != "" { + t.Fatalf("unexpected default config: %#v exists=%v err=%v", config, exists, err) + } +} + +func TestLegacyDefaultOnConfigMigratesToDisabled(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "telemetry.json") + t.Setenv(ConfigFileEnv, configPath) + t.Setenv(SuppressEnv, "") + t.Setenv("CI", "") + t.Setenv(ControlEnv, "") + t.Setenv("DO_NOT_TRACK", "") + legacy := Config{ + SchemaVersion: "1", + Enabled: true, + InstallationID: "legacy-installation-id", + DisclosedAt: "2026-08-08T00:00:00Z", + FirstCommandRecorded: true, + FirstMeaningfulUseSeen: true, + LastActiveDate: "2026-08-08", + } + if err := save(configPath, legacy); err != nil { + t.Fatalf("save legacy config: %v", err) + } + + requests := 0 + client := testClient(func(request *http.Request) (*http.Response, error) { + requests++ + _, _ = io.Copy(io.Discard, request.Body) + return noContentResponse(), nil + }) + t.Setenv(EndpointEnv, "https://telemetry.example.test/v1/events") + + Start(StartOptions{Version: "1.2.3", Command: "search", HTTPClient: client}).Finish(0) + + if requests != 0 { + t.Fatalf("legacy telemetry sent %d requests", requests) + } + config, exists, err := Load() + if err != nil || !exists || config.SchemaVersion != "2" || config.Enabled || config.InstallationID != "" || + config.DisclosedAt != "" || config.FirstCommandRecorded || config.FirstMeaningfulUseSeen || config.LastActiveDate != "" { + t.Fatalf("unexpected migrated config: %#v exists=%v err=%v", config, exists, err) + } +} + +func TestExplicitOptInSendsAllowlistedEvents(t *testing.T) { configPath := filepath.Join(t.TempDir(), "telemetry.json") t.Setenv(ConfigFileEnv, configPath) t.Setenv(SuppressEnv, "") @@ -42,15 +110,13 @@ func TestFirstRunDisclosesAndSendsAllowlistedEvents(t *testing.T) { return noContentResponse(), nil }) t.Setenv(EndpointEnv, "https://telemetry.example.test/v1/events") + if _, err := SetEnabled(true); err != nil { + t.Fatalf("enable telemetry: %v", err) + } - var disclosure strings.Builder - session := Start(StartOptions{Version: "1.2.3", Command: "validate", Stderr: &disclosure, HTTPClient: client}) + session := Start(StartOptions{Version: "1.2.3", Command: "validate", HTTPClient: client}) session.Finish(1) - if !strings.Contains(disclosure.String(), "anonymous usage and sanitized error telemetry") || - !strings.Contains(disclosure.String(), "--no-telemetry") { - t.Fatalf("missing first-run disclosure: %q", disclosure.String()) - } if len(received.Events) != 3 { t.Fatalf("expected command, first-command, and error events, got %#v", received.Events) } @@ -111,6 +177,9 @@ func TestMeaningfulUseAndDailyActivityAreBounded(t *testing.T) { return noContentResponse(), nil }) t.Setenv(EndpointEnv, "https://telemetry.example.test/v1/events") + if _, err := SetEnabled(true); err != nil { + t.Fatalf("enable telemetry: %v", err) + } Start(StartOptions{Version: "1.2.3", Command: "export html", Stderr: io.Discard, HTTPClient: client}).Finish(0) Start(StartOptions{Version: "1.2.3", Command: "export html", Stderr: io.Discard, HTTPClient: client}).Finish(0) diff --git a/packages/npm/README.md b/packages/npm/README.md index 63f0bfe..5a25f86 100644 --- a/packages/npm/README.md +++ b/packages/npm/README.md @@ -14,10 +14,10 @@ okn version The `openknowledge` and `okn` commands run the same installed CLI. -Before the CLI sends its first event, it discloses default-on anonymous usage -and sanitized error telemetry. Run `okn telemetry show-payload` to inspect a -sample. Use `okn --no-telemetry ` or `okn telemetry disable` to save an -opt-out. Telemetry does not include command arguments, paths, content, +Anonymous usage and sanitized error telemetry is disabled by default. Run +`okn telemetry show-payload` to inspect a sample. Run `okn telemetry enable` +to opt in. Use `okn --no-telemetry ` or `okn telemetry disable` to +disable it. Telemetry does not include command arguments, paths, content, identities, output, hostnames, IP addresses, or raw user agents. The package downloads the matching binary from GitHub Releases during