Skip to content

Commit 6d0dc5c

Browse files
authored
Add telemetry notice to azd (#2581)
* Add telemetry notice to azd * Add runcontext to cspell config * lll * Add tests for runcontext's Cloud Shell * Review feedback * More review feedback
1 parent 909f851 commit 6d0dc5c

File tree

7 files changed

+283
-1
lines changed

7 files changed

+283
-1
lines changed

cli/azd/.vscode/cspell.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ words:
1717
- devcontainers
1818
- goversioninfo
1919
- nosec
20+
- runcontext
2021
languageSettings:
2122
- languageId: go
2223
ignoreRegExpList:
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package runcontext
2+
3+
import (
4+
"log"
5+
"os"
6+
"strconv"
7+
)
8+
9+
const cUseCloudShellAuthEnvVar = "AZD_IN_CLOUDSHELL"
10+
11+
func IsRunningInCloudShell() bool {
12+
if azdInCloudShell, has := os.LookupEnv(cUseCloudShellAuthEnvVar); has {
13+
if use, err := strconv.ParseBool(azdInCloudShell); err == nil && use {
14+
log.Printf("running in Cloud Shell")
15+
return true
16+
}
17+
}
18+
19+
return false
20+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package runcontext
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
const cAzdInCloudShellEnvVar = "AZD_IN_CLOUDSHELL"
11+
12+
func TestIsRunningInCloudShellScenarios(t *testing.T) {
13+
t.Run("returns true when AZD_IN_CLOUDSHELL is set to true-ish string", func(t *testing.T) {
14+
t.Setenv(cAzdInCloudShellEnvVar, "1")
15+
require.True(t, IsRunningInCloudShell())
16+
})
17+
18+
t.Run("returns false when AZD_IN_CLOUDSHELL is set to false-ish string", func(t *testing.T) {
19+
t.Setenv(cAzdInCloudShellEnvVar, "0")
20+
require.False(t, IsRunningInCloudShell())
21+
})
22+
23+
t.Run("returns false when AZD_IN_CLOUDSHELL is not set", func(t *testing.T) {
24+
// Ensure that AZD_IN_CLOUDSHELL is not set otherwise the test is not
25+
// accurate
26+
_, envIsSet := os.LookupEnv(cAzdInCloudShellEnvVar)
27+
require.False(t, envIsSet)
28+
29+
require.False(t, IsRunningInCloudShell())
30+
})
31+
32+
}

cli/azd/internal/telemetry/notice.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package telemetry
2+
3+
import (
4+
"errors"
5+
"io/fs"
6+
"log"
7+
"os"
8+
"path/filepath"
9+
10+
"github.com/azure/azure-dev/cli/azd/internal/runcontext"
11+
"github.com/azure/azure-dev/cli/azd/pkg/config"
12+
)
13+
14+
// Telemetry notice text displayed to the user in some scenarios
15+
//
16+
//nolint:lll
17+
const cTelemetryNoticeText = `The Azure Developer CLI collects usage data and sends that usage data to Microsoft in order to help us improve your experience.
18+
You can opt-out of telemetry by setting the AZURE_DEV_COLLECT_TELEMETRY environment variable to 'no' in the shell you use.
19+
20+
Read more about Azure Developer CLI telemetry: https://github.com/Azure/azure-dev#data-collection`
21+
22+
// The name of the file created in the azd configuration directory after the
23+
// first run of the CLI. It's presence is used to determine if this is the
24+
// first run of the CLI.
25+
const cFirstRunFileName = "first-run"
26+
27+
func FirstNotice() string {
28+
// If the AZURE_DEV_COLLECT_TELEMETRY environment variable is set to any
29+
// value, don't display the telemetry notice. The user has either opted into
30+
// or out of telemetry already.
31+
if _, has := os.LookupEnv(collectTelemetryEnvVar); has {
32+
return ""
33+
}
34+
35+
// First run is only displayed when running in Cloud Shell
36+
if runcontext.IsRunningInCloudShell() && !noticeShown() {
37+
err := SetupFirstRun()
38+
if err != nil {
39+
log.Printf("failed to setup first run: %v", err)
40+
}
41+
42+
return cTelemetryNoticeText
43+
}
44+
45+
return ""
46+
}
47+
48+
func noticeShown() bool {
49+
firstRunFilePath, err := getFirstRunFilePath()
50+
if err != nil {
51+
log.Printf("failed to get first run file path: %v", err)
52+
// Assume no notice has been show
53+
return false
54+
}
55+
56+
if _, err := os.Stat(firstRunFilePath); err == nil {
57+
// First run file exists, this is not the first run
58+
return true
59+
} else if errors.Is(err, fs.ErrNotExist) {
60+
// The file does not exist, this is the first run
61+
return false
62+
} else {
63+
log.Printf("failed to stat first run file: %v", err)
64+
// If the first run file can't be read assume notice hasn't been shown
65+
return false
66+
}
67+
}
68+
69+
func getFirstRunFilePath() (string, error) {
70+
configDir, err := config.GetUserConfigDir()
71+
if err != nil {
72+
log.Printf("failed to get user config dir: %v", err)
73+
return "", err
74+
}
75+
76+
return filepath.Join(configDir, cFirstRunFileName), nil
77+
}
78+
79+
func SetupFirstRun() error {
80+
firstRunFilePath, err := getFirstRunFilePath()
81+
if err != nil {
82+
return err
83+
}
84+
85+
_, err = os.Create(firstRunFilePath)
86+
if err != nil {
87+
return err
88+
}
89+
90+
return nil
91+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
package telemetry
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/azure/azure-dev/cli/azd/test/ostest"
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func setupSuite(withFirstRunFile bool, t *testing.T) func(t *testing.T) {
12+
firstRunFilePath, err := getFirstRunFilePath()
13+
if err != nil {
14+
t.Fatalf("failed to get first run file path: %v", err)
15+
}
16+
17+
var tmpFilename string
18+
// If the first-run file exists
19+
if noticeShown() {
20+
21+
// Move the first-run file out of the way but keep its local contents
22+
// (if any), content can be restored on teardown.
23+
if !withFirstRunFile {
24+
file, err := os.CreateTemp("", "azd-test-")
25+
if err != nil {
26+
t.Fatalf("failed to create temp file: %v", err)
27+
}
28+
file.Close()
29+
30+
tmpFilename = file.Name()
31+
err = os.Rename(firstRunFilePath, tmpFilename)
32+
if err != nil {
33+
t.Fatalf("failed to rename first run file: %v", err)
34+
}
35+
36+
return func(t *testing.T) {
37+
err = os.Rename(tmpFilename, firstRunFilePath)
38+
if err != nil {
39+
t.Fatalf("failed to rename first run file: %v", err)
40+
}
41+
}
42+
}
43+
} else {
44+
if withFirstRunFile {
45+
// Create a first-run file to simulate the existence of a first-run
46+
// file, remove the created file on teardown.
47+
file, err := os.Create(firstRunFilePath)
48+
if err != nil {
49+
t.Fatalf("failed to create first run file: %v", err)
50+
}
51+
file.Close()
52+
53+
return func(t *testing.T) {
54+
err = os.Remove(firstRunFilePath)
55+
if err != nil {
56+
t.Fatalf("failed to remove first run file: %v", err)
57+
}
58+
}
59+
}
60+
}
61+
62+
// No setup or teardown required
63+
return func(t *testing.T) {}
64+
}
65+
66+
func Test_FirstNotice(t *testing.T) {
67+
t.Run("in Cloud Shell", func(t *testing.T) {
68+
ostest.Setenv(t, "AZD_IN_CLOUDSHELL", "1")
69+
70+
t.Run("returns nothing if opted into telemetry", func(t *testing.T) {
71+
teardown := setupSuite(false, t)
72+
defer teardown(t)
73+
74+
ostest.Setenv(t, collectTelemetryEnvVar, "yes")
75+
assert.Empty(t, FirstNotice(), "should not display telemetry notice if opted in")
76+
})
77+
78+
t.Run("returns nothing if opted out of telemetry", func(t *testing.T) {
79+
teardown := setupSuite(false, t)
80+
defer teardown(t)
81+
82+
ostest.Setenv(t, collectTelemetryEnvVar, "no")
83+
assert.Empty(t, FirstNotice(), "should not display telemetry notice if opted out")
84+
})
85+
86+
t.Run("returns nothing if first run file exists", func(t *testing.T) {
87+
teardown := setupSuite(true, t)
88+
defer teardown(t)
89+
90+
assert.Empty(t, FirstNotice(), "should not display telemetry notice if first run file exists")
91+
})
92+
})
93+
94+
t.Run("not in Cloud Shell", func(t *testing.T) {
95+
ostest.Unsetenv(t, "AZD_IN_CLOUDSHELL")
96+
97+
t.Run("returns nothing if first run file doesn't exist", func(t *testing.T) {
98+
teardown := setupSuite(false, t)
99+
defer teardown(t)
100+
101+
assert.Empty(t, FirstNotice(), "should not display telemetry notice if first run file doesn't exist")
102+
})
103+
104+
t.Run("returns nothing if opted into telemetry", func(t *testing.T) {
105+
teardown := setupSuite(false, t)
106+
defer teardown(t)
107+
108+
ostest.Setenv(t, collectTelemetryEnvVar, "yes")
109+
assert.Empty(t, FirstNotice(), "should not display telemetry notice if opted in")
110+
})
111+
112+
t.Run("returns nothing if opted out of telemetry", func(t *testing.T) {
113+
teardown := setupSuite(false, t)
114+
defer teardown(t)
115+
116+
ostest.Setenv(t, collectTelemetryEnvVar, "no")
117+
assert.Empty(t, FirstNotice(), "should not display telemetry notice if opted out")
118+
})
119+
})
120+
}

cli/azd/internal/telemetry/telemetry.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"time"
1616

1717
"github.com/azure/azure-dev/cli/azd/internal"
18+
"github.com/azure/azure-dev/cli/azd/internal/runcontext"
1819
appinsightsexporter "github.com/azure/azure-dev/cli/azd/internal/telemetry/appinsights-exporter"
1920
"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
2021
"github.com/azure/azure-dev/cli/azd/pkg/config"
@@ -65,7 +66,17 @@ func getTelemetryDirectory() (string, error) {
6566
}
6667

6768
func IsTelemetryEnabled() bool {
68-
return os.Getenv(collectTelemetryEnvVar) != "no"
69+
// If the user has opted out of telemetry directly, don't collect telemetry.
70+
if os.Getenv(collectTelemetryEnvVar) == "no" {
71+
return false
72+
}
73+
74+
// If it's the first run and we're in cloud shell, don't collect telemetry.
75+
if noticeShown() && runcontext.IsRunningInCloudShell() {
76+
return false
77+
}
78+
79+
return true
6980
}
7081

7182
// Returns the singleton TelemetrySystem instance.

cli/azd/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ func main() {
5858
go fetchLatestVersion(latest)
5959

6060
cmdErr := cmd.NewRootCmd(false, nil).ExecuteContext(ctx)
61+
62+
if !isJsonOutput() {
63+
if firstNotice := telemetry.FirstNotice(); firstNotice != "" {
64+
fmt.Fprintln(os.Stderr, output.WithWarningFormat(firstNotice))
65+
}
66+
}
67+
6168
latestVersion, ok := <-latest
6269

6370
// If we were able to fetch a latest version, check to see if we are up to date and

0 commit comments

Comments
 (0)