| // Copyright 2023 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package server |
| |
| import ( |
| "context" |
| "fmt" |
| "math/rand" |
| "os" |
| "path/filepath" |
| "strconv" |
| "testing" |
| "time" |
| |
| "golang.org/x/telemetry" |
| "golang.org/x/telemetry/counter" |
| "golang.org/x/tools/gopls/internal/protocol" |
| "golang.org/x/tools/internal/event" |
| ) |
| |
| // promptTimeout is the amount of time we wait for an ongoing prompt before |
| // prompting again. This gives the user time to reply. However, at some point |
| // we must assume that the client is not displaying the prompt, the user is |
| // ignoring it, or the prompt has been disrupted in some way (e.g. by a gopls |
| // crash). |
| const promptTimeout = 24 * time.Hour |
| |
| // gracePeriod is the amount of time we wait before sufficient telemetry data |
| // is accumulated in the local directory, so users can have time to review |
| // what kind of information will be collected and uploaded when prompting starts. |
| const gracePeriod = 7 * 24 * time.Hour |
| |
| // samplesPerMille is the prompt probability. |
| // Token is an integer between [1, 1000] and is assigned when maybePromptForTelemetry |
| // is called first time. Only the user with a token ∈ [1, samplesPerMille] |
| // will be considered for prompting. |
| const samplesPerMille = 10 // 1% sample rate |
| |
| // The following constants are used for testing telemetry integration. |
| const ( |
| TelemetryPromptWorkTitle = "Checking telemetry prompt" // progress notification title, for awaiting in tests |
| GoplsConfigDirEnvvar = "GOPLS_CONFIG_DIR" // overridden for testing |
| FakeTelemetryModefileEnvvar = "GOPLS_FAKE_TELEMETRY_MODEFILE" // overridden for testing |
| FakeSamplesPerMille = "GOPLS_FAKE_SAMPLES_PER_MILLE" // overridden for testing |
| TelemetryYes = "Yes, I'd like to help." |
| TelemetryNo = "No, thanks." |
| ) |
| |
| // The following environment variables may be set by the client. |
| // Exported for testing telemetry integration. |
| const ( |
| GoTelemetryGoplsClientStartTimeEnvvar = "GOTELEMETRY_GOPLS_CLIENT_START_TIME" // telemetry start time recored in client |
| GoTelemetryGoplsClientTokenEnvvar = "GOTELEMETRY_GOPLS_CLIENT_TOKEN" // sampling token |
| ) |
| |
| // getenv returns the effective environment variable value for the provided |
| // key, looking up the key in the session environment before falling back on |
| // the process environment. |
| func (s *server) getenv(key string) string { |
| if v, ok := s.Options().Env[key]; ok { |
| return v |
| } |
| return os.Getenv(key) |
| } |
| |
| // telemetryMode returns the current effective telemetry mode. |
| // By default this is x/telemetry.Mode(), but it may be overridden for tests. |
| func (s *server) telemetryMode() string { |
| if fake := s.getenv(FakeTelemetryModefileEnvvar); fake != "" { |
| if data, err := os.ReadFile(fake); err == nil { |
| return string(data) |
| } |
| return "local" |
| } |
| return telemetry.Mode() |
| } |
| |
| // setTelemetryMode sets the current telemetry mode. |
| // By default this calls x/telemetry.SetMode, but it may be overridden for |
| // tests. |
| func (s *server) setTelemetryMode(mode string) error { |
| if fake := s.getenv(FakeTelemetryModefileEnvvar); fake != "" { |
| return os.WriteFile(fake, []byte(mode), 0666) |
| } |
| return telemetry.SetMode(mode) |
| } |
| |
| // maybePromptForTelemetry checks for the right conditions, and then prompts |
| // the user to ask if they want to enable Go telemetry uploading. If the user |
| // responds 'Yes', the telemetry mode is set to "on". |
| // |
| // The actual conditions for prompting are defensive, erring on the side of not |
| // prompting. |
| // If enabled is false, this will not prompt the user in any condition, |
| // but will send work progress reports to help testing. |
| func (s *server) maybePromptForTelemetry(ctx context.Context, enabled bool) { |
| if s.Options().VerboseWorkDoneProgress { |
| work := s.progress.Start(ctx, TelemetryPromptWorkTitle, "Checking if gopls should prompt about telemetry...", nil, nil) |
| defer work.End(ctx, "Done.") |
| } |
| |
| errorf := func(format string, args ...any) { |
| err := fmt.Errorf(format, args...) |
| event.Error(ctx, "telemetry prompt failed", err) |
| } |
| |
| // Only prompt if we can read/write the prompt config file. |
| configDir := s.getenv(GoplsConfigDirEnvvar) // set for testing |
| if configDir == "" && testing.Testing() { |
| // Unless tests set GoplsConfigDirEnvvar, the prompt is a no op. |
| // We don't want tests to interact with os.UserConfigDir(). |
| return |
| } |
| if configDir == "" { |
| userDir, err := os.UserConfigDir() |
| if err != nil { |
| errorf("unable to determine user config dir: %v", err) |
| return |
| } |
| configDir = filepath.Join(userDir, "gopls") |
| } |
| |
| // Read the current prompt file. |
| |
| var ( |
| promptDir = filepath.Join(configDir, "prompt") // prompt configuration directory |
| promptFile = filepath.Join(promptDir, "telemetry") // telemetry prompt file |
| ) |
| |
| // prompt states, stored in the prompt file |
| const ( |
| pUnknown = "" // first time |
| pNotReady = "-" // user is not asked yet (either not sampled or not past the grace period) |
| pYes = "yes" // user said yes |
| pNo = "no" // user said no |
| pPending = "pending" // current prompt is still pending |
| pFailed = "failed" // prompt was asked but failed |
| ) |
| validStates := map[string]bool{ |
| pNotReady: true, |
| pYes: true, |
| pNo: true, |
| pPending: true, |
| pFailed: true, |
| } |
| |
| // Parse the current prompt file. |
| var ( |
| state = pUnknown |
| attempts = 0 // number of times we've asked already |
| |
| // the followings are recorded after gopls v0.17+. |
| token = 0 // valid token is [1, 1000] |
| creationTime int64 // unix time sec |
| ) |
| if content, err := os.ReadFile(promptFile); err == nil { |
| if n, _ := fmt.Sscanf(string(content), "%s %d %d %d", &state, &attempts, &creationTime, &token); (n == 2 || n == 4) && validStates[state] { |
| // successfully parsed! |
| // ~ v0.16: must have only two fields, state and attempts. |
| // v0.17 ~: must have all four fields. |
| } else { |
| state, attempts, creationTime, token = pUnknown, 0, 0, 0 |
| // TODO(hyangah): why do we want to present this as an error to user? |
| errorf("malformed prompt result %q", string(content)) |
| } |
| } else if !os.IsNotExist(err) { |
| errorf("reading prompt file: %v", err) |
| // Something went wrong. Since we don't know how many times we've asked the |
| // prompt, err on the side of not asking. |
| // |
| // But record this in telemetry, in case some users enable telemetry by |
| // other means. |
| counter.New("gopls/telemetryprompt/corrupted").Inc() |
| return |
| } |
| |
| counter.New(fmt.Sprintf("gopls/telemetryprompt/attempts:%d", attempts)).Inc() |
| |
| // Check terminal conditions. |
| |
| if state == pYes { |
| // Prompt has been accepted. |
| // |
| // We record this counter for every gopls session, rather than when the |
| // prompt actually accepted below, because if we only recorded it in the |
| // counter file at the time telemetry is enabled, we'd never upload it, |
| // because we exclude any counter files that overlap with a time period |
| // that has telemetry uploading is disabled. |
| counter.New("gopls/telemetryprompt/accepted").Inc() |
| return |
| } |
| if state == pNo { |
| // Prompt has been declined. In most cases, this means we'll never see the |
| // counter below, but it's possible that the user may enable telemetry by |
| // other means later on. If we see a significant number of users that have |
| // accepted telemetry but declined the prompt, it may be an indication that |
| // the prompt is not working well. |
| counter.New("gopls/telemetryprompt/declined").Inc() |
| return |
| } |
| if attempts >= 5 { // pPending or pFailed |
| // We've tried asking enough; give up. Record that the prompt expired, in |
| // case the user decides to enable telemetry by other means later on. |
| // (see also the pNo case). |
| counter.New("gopls/telemetryprompt/expired").Inc() |
| return |
| } |
| |
| // We only check enabled after (1) the work progress is started, and (2) the |
| // prompt file has been read. (1) is for testing purposes, and (2) is so that |
| // we record the "gopls/telemetryprompt/accepted" counter for every session. |
| if !enabled { |
| return // prompt is disabled |
| } |
| |
| if s.telemetryMode() == "on" || s.telemetryMode() == "off" { |
| // Telemetry is already on or explicitly off -- nothing to ask about. |
| return |
| } |
| |
| // Transition: pUnknown -> pNotReady |
| if state == pUnknown { |
| // First time; we need to make the prompt dir. |
| if err := os.MkdirAll(promptDir, 0777); err != nil { |
| errorf("creating prompt dir: %v", err) |
| return |
| } |
| state = pNotReady |
| } |
| |
| // Correct missing values. |
| if creationTime == 0 { |
| creationTime = time.Now().Unix() |
| if v := s.getenv(GoTelemetryGoplsClientStartTimeEnvvar); v != "" { |
| if sec, err := strconv.ParseInt(v, 10, 64); err == nil && sec > 0 { |
| creationTime = sec |
| } |
| } |
| } |
| if token == 0 { |
| token = rand.Intn(1000) + 1 |
| if v := s.getenv(GoTelemetryGoplsClientTokenEnvvar); v != "" { |
| if tok, err := strconv.Atoi(v); err == nil && 1 <= tok && tok <= 1000 { |
| token = tok |
| } |
| } |
| } |
| |
| // Transition: pNotReady -> pPending if sampled |
| if state == pNotReady { |
| threshold := samplesPerMille |
| if v := s.getenv(FakeSamplesPerMille); v != "" { |
| if t, err := strconv.Atoi(v); err == nil { |
| threshold = t |
| } |
| } |
| if token <= threshold && time.Now().Unix()-creationTime > gracePeriod.Milliseconds()/1000 { |
| state = pPending |
| } |
| } |
| |
| // Acquire the lock and write the updated state to the prompt file before actually |
| // prompting. |
| // |
| // This ensures that the prompt file is writeable, and that we increment the |
| // attempt counter before we prompt, so that we don't end up in a failure |
| // mode where we keep prompting and then failing to record the response. |
| |
| release, ok, err := acquireLockFile(promptFile) |
| if err != nil { |
| errorf("acquiring prompt: %v", err) |
| return |
| } |
| if !ok { |
| // Another process is making decision. |
| return |
| } |
| defer release() |
| |
| if state != pNotReady { // pPending or pFailed |
| attempts++ |
| } |
| |
| pendingContent := []byte(fmt.Sprintf("%s %d %d %d", state, attempts, creationTime, token)) |
| if err := os.WriteFile(promptFile, pendingContent, 0666); err != nil { |
| errorf("writing pending state: %v", err) |
| return |
| } |
| |
| if state == pNotReady { |
| return |
| } |
| |
| var prompt = `Go telemetry helps us improve Go by periodically sending anonymous metrics and crash reports to the Go team. Learn more at https://go.dev/doc/telemetry. |
| |
| Would you like to enable Go telemetry? |
| ` |
| if s.Options().LinkifyShowMessage { |
| prompt = `Go telemetry helps us improve Go by periodically sending anonymous metrics and crash reports to the Go team. Learn more at [go.dev/doc/telemetry](https://go.dev/doc/telemetry). |
| |
| Would you like to enable Go telemetry? |
| ` |
| } |
| // TODO(rfindley): investigate a "tell me more" action in combination with ShowDocument. |
| params := &protocol.ShowMessageRequestParams{ |
| Type: protocol.Info, |
| Message: prompt, |
| Actions: []protocol.MessageActionItem{ |
| {Title: TelemetryYes}, |
| {Title: TelemetryNo}, |
| }, |
| } |
| |
| item, err := s.client.ShowMessageRequest(ctx, params) |
| if err != nil { |
| errorf("ShowMessageRequest failed: %v", err) |
| // Defensive: ensure item == nil for the logic below. |
| item = nil |
| } |
| |
| message := func(typ protocol.MessageType, msg string) { |
| if !showMessage(ctx, s.client, typ, msg) { |
| // Make sure we record that "telemetry prompt failed". |
| errorf("showMessage failed: %v", err) |
| } |
| } |
| |
| result := pFailed |
| if item == nil { |
| // e.g. dialog was dismissed |
| errorf("no response") |
| } else { |
| // Response matches MessageActionItem.Title. |
| switch item.Title { |
| case TelemetryYes: |
| result = pYes |
| if err := s.setTelemetryMode("on"); err == nil { |
| message(protocol.Info, telemetryOnMessage(s.Options().LinkifyShowMessage)) |
| } else { |
| errorf("enabling telemetry failed: %v", err) |
| msg := fmt.Sprintf("Failed to enable Go telemetry: %v\nTo enable telemetry manually, please run `go run golang.org/x/telemetry/cmd/gotelemetry@latest on`", err) |
| message(protocol.Error, msg) |
| } |
| |
| case TelemetryNo: |
| result = pNo |
| default: |
| errorf("unrecognized response %q", item.Title) |
| message(protocol.Error, fmt.Sprintf("Unrecognized response %q", item.Title)) |
| } |
| } |
| resultContent := []byte(fmt.Sprintf("%s %d %d %d", result, attempts, creationTime, token)) |
| if err := os.WriteFile(promptFile, resultContent, 0666); err != nil { |
| errorf("error writing result state to prompt file: %v", err) |
| } |
| } |
| |
| func telemetryOnMessage(linkify bool) string { |
| format := `Thank you. Telemetry uploading is now enabled. |
| |
| To disable telemetry uploading, run %s. |
| ` |
| var runCmd = "`go run golang.org/x/telemetry/cmd/gotelemetry@latest local`" |
| if linkify { |
| runCmd = "[gotelemetry local](https://golang.org/x/telemetry/cmd/gotelemetry)" |
| } |
| return fmt.Sprintf(format, runCmd) |
| } |
| |
| // acquireLockFile attempts to "acquire a lock" for writing to path. |
| // |
| // This is achieved by creating an exclusive lock file at <path>.lock. Lock |
| // files expire after a period, at which point acquireLockFile will remove and |
| // recreate the lock file. |
| // |
| // acquireLockFile fails if path is in a directory that doesn't exist. |
| func acquireLockFile(path string) (func(), bool, error) { |
| lockpath := path + ".lock" |
| fi, err := os.Stat(lockpath) |
| if err == nil { |
| if time.Since(fi.ModTime()) > promptTimeout { |
| _ = os.Remove(lockpath) // ignore error |
| } else { |
| return nil, false, nil |
| } |
| } else if !os.IsNotExist(err) { |
| return nil, false, fmt.Errorf("statting lockfile: %v", err) |
| } |
| |
| f, err := os.OpenFile(lockpath, os.O_CREATE|os.O_EXCL, 0666) |
| if err != nil { |
| if os.IsExist(err) { |
| return nil, false, nil |
| } |
| return nil, false, fmt.Errorf("creating lockfile: %v", err) |
| } |
| fi, err = f.Stat() |
| if err != nil { |
| return nil, false, err |
| } |
| release := func() { |
| _ = f.Close() // ignore error |
| fi2, err := os.Stat(lockpath) |
| if err == nil && os.SameFile(fi, fi2) { |
| // Only clean up the lockfile if it's the same file we created. |
| // Otherwise, our lock has expired and something else has the lock. |
| // |
| // There's a race here, in that the file could have changed since the |
| // stat above; but given that we've already waited 24h this is extremely |
| // unlikely, and acceptable. |
| _ = os.Remove(lockpath) |
| } |
| } |
| return release, true, nil |
| } |