File: prompt.go

package info (click to toggle)
golang-golang-x-tools 1%3A0.25.0%2Bds-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 22,724 kB
  • sloc: javascript: 2,027; asm: 1,645; sh: 166; yacc: 155; makefile: 49; ansic: 8
file content (419 lines) | stat: -rw-r--r-- 14,156 bytes parent folder | download
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
}