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
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions docs/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 10 additions & 8 deletions internal/cli/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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 {
Expand Down
47 changes: 26 additions & 21 deletions internal/cli/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}