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 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419
|
// 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"
"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)
}
// 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.")
}
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
}
// 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
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
}
|