blob: 37f591487a6294a2cbd2b65c3379d700a3246aa0 [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"
"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
}