blob: a1dba8d234d82f0647efc97c57acf170f0806a56 [file] [log] [blame]
// 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"
"os"
"path/filepath"
"time"
"golang.org/x/telemetry"
"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
// 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
TelemetryYes = "Yes, I'd like to help."
TelemetryNo = "No, thanks."
)
// 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)
}
// configDir returns the root of the gopls configuration dir. By default this
// is os.UserConfigDir/gopls, but it may be overridden for tests.
func (s *server) configDir() (string, error) {
if d := s.getenv(GoplsConfigDirEnvvar); d != "" {
return d, nil
}
userDir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(userDir, "gopls"), nil
}
// 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.")
}
if !enabled { // check this after the work progress message for testing.
return // prompt is disabled
}
if s.telemetryMode() == "on" || s.telemetryMode() == "off" {
// Telemetry is already on or explicitly off -- nothing to ask about.
return
}
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, err := s.configDir()
if err != nil {
errorf("unable to determine config dir: %v", err)
return
}
var (
promptDir = filepath.Join(configDir, "prompt") // prompt configuration directory
promptFile = filepath.Join(promptDir, "telemetry") // telemetry prompt file
)
// prompt states, to be written to the prompt file
const (
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{
pYes: true,
pNo: true,
pPending: true,
pFailed: true,
}
// parse the current prompt file
var (
state string
attempts = 0 // number of times we've asked already
)
if content, err := os.ReadFile(promptFile); err == nil {
if _, err := fmt.Sscanf(string(content), "%s %d", &state, &attempts); err == nil && validStates[state] {
if state == pYes || state == pNo {
// Prompt has been answered. Nothing to do.
return
}
} else {
state, attempts = "", 0
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 spamming.
return
}
if attempts >= 5 {
// We've tried asking enough; give up.
return
}
if attempts == 0 {
// First time asking the prompt; we may need to make the prompt dir.
if err := os.MkdirAll(promptDir, 0777); err != nil {
errorf("creating prompt dir: %v", err)
return
}
}
// Acquire the lock and write "pending" 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 prompt is currently pending.
return
}
defer release()
attempts++
pendingContent := []byte(fmt.Sprintf("%s %d", pPending, attempts))
if err := os.WriteFile(promptFile, pendingContent, 0666); err != nil {
errorf("writing pending state: %v", err)
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", result, attempts))
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
}