From 5b1bfa90b5e218b01e34f73f49af30ea1878b3d2 Mon Sep 17 00:00:00 2001 From: Jonathan Moss <2729151+jwmoss@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:03:45 -0400 Subject: [PATCH] fix: print the telemetry notice before any event is sent The notice now runs in beginUsage, ahead of the command, and prints in every output mode until the user saves a preference. Redirected, --json, --plain, and --quiet sessions were never informed before. --- CHANGELOG.md | 5 ++-- docs/telemetry.md | 5 ++-- internal/cli/telemetry.go | 18 +++++++------ internal/cli/telemetry_test.go | 47 +++++++++++++++++++--------------- 4 files changed, 42 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 055fc3a..0061bd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,9 @@ This project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Telemetry is on by default for interactive sessions. CI and agent sessions still send nothing unless `DATATF_TELEMETRY=1` is set. `DO_NOT_TRACK`, `DATATF_TELEMETRY=0`, and `datatf telemetry disable` still opt out. Invalid consent files still turn telemetry off. -- Interactive `inventory` and `export` commands print a one-line telemetry notice on stderr until - you save a preference with `datatf telemetry enable` or `datatf telemetry disable`. +- `inventory` and `export` print a short telemetry notice on stderr before they run and before any + event is sent, in every output mode, until you save a preference with `datatf telemetry enable` + or `datatf telemetry disable`. ## [1.0.2] - 2026-09-09 diff --git a/docs/telemetry.md b/docs/telemetry.md index 66a272f..9466b32 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -20,8 +20,9 @@ datatf telemetry disable `enable` saves your consent for future eligible commands. `disable` saves an opt-out. It does not delete events that the collector already received. -Until you save a preference, interactive `inventory` and `export` commands print a short notice -on stderr with a link to this page. `--json`, `--plain`, and `--quiet` suppress the notice. +Until you save a preference, `inventory` and `export` print a short notice on stderr with a link +to this page before they run and before any event is sent. The notice prints in every output mode, +including `--json`, `--plain`, `--quiet`, and redirected output. `enable` or `disable` silences it. `status --json` shows the effective preference, its source, and the settings path. `preview` prints a sample JSON event through the same encoder that sends events. diff --git a/internal/cli/telemetry.go b/internal/cli/telemetry.go index 4c02f67..f728b89 100644 --- a/internal/cli/telemetry.go +++ b/internal/cli/telemetry.go @@ -86,11 +86,14 @@ func (rc *runtime) telemetryAction(action string) error { return nil } -// writeTelemetryNotice tells interactive users about default telemetry until they save a preference. +// writeTelemetryNotice runs before the command and before any event leaves the machine. +// It prints in every output mode until the user saves a preference, so a redirected, +// --json, --plain, or --quiet session still learns about default telemetry. func (rc *runtime) writeTelemetryNotice() { _, _ = fmt.Fprintf(rc.stderr, - "\nDataTF sends optional usage metrics without workspace metadata. Notice: %s\n"+ - "Run `datatf telemetry disable` or set DATATF_TELEMETRY=0 to opt out.\n", + "DataTF sends optional usage metrics without workspace metadata. Notice: %s\n"+ + "Run `datatf telemetry disable` or set DATATF_TELEMETRY=0 to opt out. "+ + "Run `datatf telemetry enable` to keep it and silence this notice.\n", telemetry.Notice) } @@ -115,19 +118,18 @@ func (rc *runtime) beginUsage(command string) { Command: command, Scope: "none", Outcome: "complete", ErrorCode: "none", } rc.usageStarted = time.Now() + if status := telemetryStatus(); status.Enabled && status.Source == "default" { + rc.writeTelemetryNotice() + } } func (rc *runtime) finishUsage(failed bool) { if rc.usage == nil { return } - status := telemetryStatus() - if !status.Enabled { + if !telemetryStatus().Enabled { return } - if status.Source == "default" && rc.updateOutputAllowed() { - rc.writeTelemetryNotice() - } if failed { rc.usage.Outcome = "error" switch rc.usage.ErrorCode { diff --git a/internal/cli/telemetry_test.go b/internal/cli/telemetry_test.go index e985645..a174a9f 100644 --- a/internal/cli/telemetry_test.go +++ b/internal/cli/telemetry_test.go @@ -315,40 +315,45 @@ func TestTelemetryNoticeUntilPreferenceSaved(t *testing.T) { t.Setenv("DATATF_TELEMETRY", "") t.Setenv("DATATF_NO_UPDATE_NOTIFIER", "1") oldTerminal := terminalOutput - terminalOutput = func(io.Writer) bool { return true } + terminalOutput = func(io.Writer) bool { return false } t.Cleanup(func() { terminalOutput = oldTerminal }) - out := t.TempDir() + out := func() string { return t.TempDir() } - code, stdout, stderr := run(t, "inventory", "--resources", "warehouses", "--out", out) - if code != exitOK || !strings.Contains(stderr, "datatf telemetry disable") || - !strings.Contains(stderr, telemetry.Notice) { - t.Fatalf("default consent must print a notice: %d %s", code, stderr) + // The notice must reach redirected, --json, --plain, and --quiet sessions before any send. + for _, flag := range []string{"", "--json", "--plain", "--quiet"} { + args := []string{"inventory", "--resources", "warehouses", "--out", out()} + if flag != "" { + args = append(args, flag) + } + code, stdout, stderr := run(t, args...) + if code != exitOK || !strings.Contains(stderr, "datatf telemetry disable") || + !strings.Contains(stderr, telemetry.Notice) { + t.Fatalf("%v: default consent must print a notice: %d %s", args, code, stderr) + } + if strings.Contains(stdout, "telemetry") { + t.Fatalf("%v: notice changed stdout", args) + } } - if strings.Contains(stdout, "telemetry") { - t.Fatal("notice changed stdout") + if len(*events) != 4 { + t.Fatalf("default consent must send one event per run: %v", *events) } - if len(*events) != 1 { - t.Fatalf("default consent must send one event: %v", *events) + if _, _, stderr := run(t, "telemetry", "status"); strings.Contains(stderr, "telemetry disable") { + t.Fatalf("notice for an offline control: %s", stderr) } - for _, args := range [][]string{ - {"inventory", "--resources", "warehouses", "--out", out, "--json"}, - {"inventory", "--resources", "warehouses", "--out", out, "--plain"}, - {"inventory", "--resources", "warehouses", "--out", out, "--quiet"}, - {"telemetry", "status"}, - } { - if _, _, stderr := run(t, args...); strings.Contains(stderr, "telemetry disable") { - t.Fatalf("notice for %v: %s", args, stderr) - } + // A command that fails after it starts still prints the notice before its event is sent. + code, _, stderr := run(t, "export", "--scope", "invalid", "--out", out()) + if code == exitOK || !strings.Contains(stderr, "telemetry disable") || len(*events) != 5 { + t.Fatalf("failed command must notify before sending: %d %s %d", code, stderr, len(*events)) } for _, action := range []string{"enable", "disable"} { run(t, "telemetry", action) - if _, _, stderr := run(t, "inventory", "--resources", "warehouses", "--out", out); strings.Contains(stderr, "telemetry disable") { + if _, _, stderr := run(t, "inventory", "--resources", "warehouses", "--out", out()); strings.Contains(stderr, "telemetry disable") { t.Fatalf("notice after saved %s: %s", action, stderr) } } - if len(*events) != 5 { + if len(*events) != 6 { t.Fatalf("expected events for default and enabled runs only: %d", len(*events)) } }