1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315
|
// 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"
"cuelang.org/go/internal/golangorgx/gopls/protocol"
"cuelang.org/go/internal/golangorgx/gopls/telemetry"
"cuelang.org/go/internal/golangorgx/tools/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://telemetry.go.dev/privacy.
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 [telemetry.go.dev/privacy](https://telemetry.go.dev/privacy).
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 cuelang.org/go/internal/golangorgx/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 cuelang.org/go/internal/golangorgx/telemetry/cmd/gotelemetry@latest local`"
if linkify {
runCmd = "[gotelemetry local](https://cuelang.org/go/internal/golangorgx/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
}
|