From 7e2e5ad5dcc4cff1b70f3583321bfc5abe599035 Mon Sep 17 00:00:00 2001 From: "Hana (Hyang-Ah) Kim" Date: Thu, 5 Oct 2023 17:39:30 -0400 Subject: [PATCH] src/goTelemetry: ask gopls to prompt for telemetry opt-in We want to avoid overwhelming users with the telemetry opt-in prompt when gopls starts. Instead of relying on the setting, the extension monitors the user's activities - open/close files, typing. And tell gopls when it thinks is a good time to show prompt, by calling the gopls's maybe_prompt_for_telemetry command. And for this initial rollout, we aim to reach out to 0.1% of users who didn't disable the vscode's general telemetry. Since go telemetry is pretty different wrt privacy preserving, we will eventually remove the vscode's general telemetry preference checking. We also ask all preview extension users - we think there are not many those users. Change-Id: Iced5bdcf61d7fd4276fd3b5cc61d6dc9686cf6d4 Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/533315 Reviewed-by: Robert Findley Reviewed-by: Suzy Mueller TryBot-Result: kokoro Commit-Queue: Hyang-Ah Hana Kim --- src/commands/startLanguageServer.ts | 7 ++ src/context.ts | 2 + src/goTelemetry.ts | 82 ++++++++++++++ src/language/goLanguageServer.ts | 55 ++++++++-- test/gopls/extension.test.ts | 23 ++++ test/gopls/telemetry.test.ts | 159 ++++++++++++++++++++++++++++ 6 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 src/goTelemetry.ts create mode 100644 test/gopls/telemetry.test.ts diff --git a/src/commands/startLanguageServer.ts b/src/commands/startLanguageServer.ts index 351d691e5f..42ccefa75b 100644 --- a/src/commands/startLanguageServer.ts +++ b/src/commands/startLanguageServer.ts @@ -26,6 +26,7 @@ import { } from '../language/goLanguageServer'; import { LegacyLanguageService } from '../language/registerDefaultProviders'; import { Mutex } from '../utils/mutex'; +import { TelemetryService } from '../goTelemetry'; const languageServerStartMutex = new Mutex(); @@ -95,6 +96,12 @@ export const startLanguageServer: CommandFactory = (ctx, goCtx) => { goCtx.languageClient = await buildLanguageClient(goCtx, buildLanguageClientOption(goCtx, cfg)); await goCtx.languageClient.start(); goCtx.serverInfo = toServerInfo(goCtx.languageClient.initializeResult); + goCtx.telemetryService = new TelemetryService( + goCtx.languageClient, + ctx.globalState, + goCtx.serverInfo?.Commands + ); + updateStatus(goCtx, goConfig, true); console.log(`Server: ${JSON.stringify(goCtx.serverInfo, null, 2)}`); } catch (e) { diff --git a/src/context.ts b/src/context.ts index e5562cf81b..6265d14dc9 100644 --- a/src/context.ts +++ b/src/context.ts @@ -8,6 +8,7 @@ import { LanguageClient } from 'vscode-languageclient/node'; import { LanguageServerConfig, Restart, ServerInfo } from './language/goLanguageServer'; import { LegacyLanguageService } from './language/registerDefaultProviders'; +import { TelemetryService } from './goTelemetry'; // Global variables used for management of the language client. // They are global so that the server can be easily restarted with @@ -16,6 +17,7 @@ export interface GoExtensionContext { languageClient?: LanguageClient; legacyLanguageService?: LegacyLanguageService; latestConfig?: LanguageServerConfig; + telemetryService?: TelemetryService; serverOutputChannel?: vscode.OutputChannel; // server-side output. serverTraceChannel?: vscode.OutputChannel; // client-side tracing. govulncheckOutputChannel?: vscode.OutputChannel; // govulncheck output. diff --git a/src/goTelemetry.ts b/src/goTelemetry.ts new file mode 100644 index 0000000000..cb91911db1 --- /dev/null +++ b/src/goTelemetry.ts @@ -0,0 +1,82 @@ +/*--------------------------------------------------------- + * Copyright 2023 The Go Authors. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +import { createHash } from 'crypto'; +import { ExecuteCommandRequest } from 'vscode-languageserver-protocol'; +import { daysBetween } from './goSurvey'; +import { LanguageClient } from 'vscode-languageclient/node'; + +// Name of the prompt telemetry command. This is also used to determine if the gopls instance supports telemetry. +// Exported for testing. +export const GOPLS_MAYBE_PROMPT_FOR_TELEMETRY = 'gopls.maybe_prompt_for_telemetry'; + +// Key for the global state that holds the very first time the telemetry-enabled gopls was observed. +// Exported for testing. +export const TELEMETRY_START_TIME_KEY = 'telemetryStartTime'; + +// Go extension delegates most of the telemetry logic to gopls. +// TelemetryService provides API to interact with gopls's telemetry. +export class TelemetryService { + private active = false; + constructor( + private languageClient: Pick | undefined, + private globalState: vscode.Memento, + commands: string[] = [] + ) { + if (!languageClient || !commands.includes(GOPLS_MAYBE_PROMPT_FOR_TELEMETRY)) { + // we are not backed by the gopls version that supports telemetry. + return; + } + + this.active = true; + // record the first time we see the gopls with telemetry support. + // The timestamp will be used to avoid prompting too early. + const telemetryStartTime = globalState.get(TELEMETRY_START_TIME_KEY); + if (!telemetryStartTime) { + globalState.update(TELEMETRY_START_TIME_KEY, new Date()); + } + } + + async promptForTelemetry( + isPreviewExtension: boolean, + isVSCodeTelemetryEnabled: boolean = vscode.env.isTelemetryEnabled, + samplingInterval = 1 /* prompt N out of 1000. 1 = 0.1% */ + ) { + if (!this.active) return; + + // Do not prompt yet if the user disabled vscode's telemetry. + // TODO(hyangah): remove this condition after we roll out to 100%. It's possible + // users who don't want vscode's telemetry are still willing to opt in. + if (!isVSCodeTelemetryEnabled) return; + + // Allow at least 7days for gopls to collect some data. + const now = new Date(); + const telemetryStartTime = this.globalState.get(TELEMETRY_START_TIME_KEY, now); + if (daysBetween(telemetryStartTime, now) < 7) { + return; + } + + // For official extension users, prompt only N out of 1000. + if (!isPreviewExtension && this.hashMachineID() % 1000 >= samplingInterval) { + return; + } + + try { + await this.languageClient?.sendRequest(ExecuteCommandRequest.type, { + command: GOPLS_MAYBE_PROMPT_FOR_TELEMETRY + }); + } catch (e) { + console.log(`failed to send telemetry request: ${e}`); + } + } + + // exported for testing. + public hashMachineID(salt?: string): number { + const hash = createHash('md5').update(`${vscode.env.machineId}${salt}`).digest('hex'); + return parseInt(hash.substring(0, 8), 16); + } +} diff --git a/src/language/goLanguageServer.ts b/src/language/goLanguageServer.ts index d2521a5200..a3f9f9ddfe 100644 --- a/src/language/goLanguageServer.ts +++ b/src/language/goLanguageServer.ts @@ -40,7 +40,6 @@ import { getGoConfig, getGoplsConfig, extensionInfo } from '../config'; import { toolExecutionEnvironment } from '../goEnv'; import { GoDocumentFormattingEditProvider, usingCustomFormatTool } from './legacy/goFormat'; import { installTools, latestToolVersion, promptForMissingTool, promptForUpdatingTool } from '../goInstallTools'; -import { GoExtensionContext } from '../context'; import { getTool, Tool } from '../goTools'; import { getFromGlobalState, updateGlobalState, updateWorkspaceState } from '../stateUtils'; import { @@ -62,6 +61,7 @@ import { updateLanguageServerIconGoStatusBar } from '../goStatus'; import { URI } from 'vscode-uri'; import { VulncheckReport, writeVulns } from '../goVulncheck'; import { createHash } from 'crypto'; +import { GoExtensionContext } from '../context'; export interface LanguageServerConfig { serverName: string; @@ -161,6 +161,9 @@ export function scheduleGoplsSuggestions(goCtx: GoExtensionContext) { const usingGopls = (cfg: LanguageServerConfig): boolean => { return cfg.enabled && cfg.serverName === 'gopls'; }; + const usingGo = (): boolean => { + return vscode.workspace.textDocuments.some((doc) => doc.languageId === 'go'); + }; const installGopls = async (cfg: LanguageServerConfig) => { const tool = getTool('gopls'); const versionToUpdate = await shouldUpdateLanguageServer(tool, cfg); @@ -203,22 +206,22 @@ export function scheduleGoplsSuggestions(goCtx: GoExtensionContext) { }; const survey = async () => { setTimeout(survey, timeDay); - // Only prompt for the survey if the user is working on Go code. - let foundGo = false; - for (const doc of vscode.workspace.textDocuments) { - if (doc.languageId === 'go') { - foundGo = true; - } - } - if (!foundGo) { + if (!usingGo) { return; } maybePromptForGoplsSurvey(goCtx); maybePromptForDeveloperSurvey(goCtx); }; + const telemetry = () => { + if (!usingGo) { + return; + } + maybePromptForTelemetry(goCtx); + }; setTimeout(update, 10 * timeMinute); setTimeout(survey, 30 * timeMinute); + setTimeout(telemetry, 6 * timeMinute); } // Ask users to fill out opt-out survey. @@ -309,6 +312,7 @@ const race = function (promise: Promise, timeoutInMilliseconds: number) export async function stopLanguageClient(goCtx: GoExtensionContext) { const c = goCtx.languageClient; goCtx.crashCount = 0; + goCtx.telemetryService = undefined; goCtx.languageClient = undefined; if (!c) return false; @@ -825,12 +829,13 @@ async function adjustGoplsWorkspaceConfiguration( return workspaceConfig; } - workspaceConfig = filterGoplsDefaultConfigValues(workspaceConfig, resource); + workspaceConfig = filterGoplsDefaultConfigValues(workspaceConfig, resource) || {}; // note: workspaceConfig is a modifiable, valid object. const goConfig = getGoConfig(resource); workspaceConfig = passGoConfigToGoplsConfigValues(workspaceConfig, goConfig); workspaceConfig = await passInlayHintConfigToGopls(cfg, workspaceConfig, goConfig); workspaceConfig = await passVulncheckConfigToGopls(cfg, workspaceConfig, goConfig); + workspaceConfig = await passLinkifyShowMessageToGopls(cfg, workspaceConfig); // Only modify the user's configurations for the Nightly. if (!extensionInfo.isPreview) { @@ -868,6 +873,20 @@ async function passVulncheckConfigToGopls(cfg: LanguageServerConfig, goplsConfig return goplsConfig; } +async function passLinkifyShowMessageToGopls(cfg: LanguageServerConfig, goplsConfig: any) { + goplsConfig = goplsConfig ?? {}; + + const goplsVersion = await getLocalGoplsVersion(cfg); + if (!goplsVersion) return goplsConfig; + + const version = semver.parse(goplsVersion.version); + // The linkifyShowMessage option was added in v0.14.0-pre.1. + if ((version?.compare('0.13.99') ?? 1) > 0) { + goplsConfig['linkifyShowMessage'] = true; + } + return goplsConfig; +} + // createTestCodeLens adds the go.test.cursor and go.debug.cursor code lens function createTestCodeLens(lens: vscode.CodeLens): vscode.CodeLens[] { // CodeLens argument signature in gopls is [fileName: string, testFunctions: string[], benchFunctions: string[]], @@ -1621,3 +1640,19 @@ async function goplsFetchVulncheckResult(goCtx: GoExtensionContext, uri: string) const res = await languageClient?.sendRequest(ExecuteCommandRequest.type, params); return res[uri]; } + +export function maybePromptForTelemetry(goCtx: GoExtensionContext) { + const callback = async () => { + const { lastUserAction = new Date() } = goCtx; + const currentTime = new Date(); + + // Make sure the user has been idle for at least 5 minutes. + const idleTime = currentTime.getTime() - lastUserAction.getTime(); + if (idleTime < 5 * timeMinute) { + setTimeout(callback, 5 * timeMinute - Math.max(idleTime, 0)); + return; + } + goCtx.telemetryService?.promptForTelemetry(extensionInfo.isPreview); + }; + callback(); +} diff --git a/test/gopls/extension.test.ts b/test/gopls/extension.test.ts index 32245cefa5..2fd36d6d46 100644 --- a/test/gopls/extension.test.ts +++ b/test/gopls/extension.test.ts @@ -16,6 +16,8 @@ import { } from '../../src/language/goLanguageServer'; import sinon = require('sinon'); import { getGoVersion, GoVersion } from '../../src/util'; +import { GOPLS_MAYBE_PROMPT_FOR_TELEMETRY, TELEMETRY_START_TIME_KEY, TelemetryService } from '../../src/goTelemetry'; +import { MockMemento } from '../mocks/MockMemento'; // FakeOutputChannel is a fake output channel used to buffer // the output of the tested language client in an in-memory @@ -327,4 +329,25 @@ suite('Go Extension Tests With Gopls', function () { await testCustomFormatter(goConfig, customFormatter); }); + + test('Prompt For telemetry', async () => { + await env.startGopls(path.resolve(testdataDir, 'gogetdocTestData', 'test.go')); + const memento = new MockMemento(); + memento.update(TELEMETRY_START_TIME_KEY, new Date('2000-01-01')); + + const sut = new TelemetryService(env.languageClient, memento, [GOPLS_MAYBE_PROMPT_FOR_TELEMETRY]); + try { + await Promise.all([ + // we want to see the prompt command flowing. + env.onMessageInTrace(GOPLS_MAYBE_PROMPT_FOR_TELEMETRY, 60_000), + sut.promptForTelemetry( + false /* not a preview */, + true /* vscode telemetry not disabled */, + 1000 /* 1000 out of 1000 users */ + ) + ]); + } catch (e) { + assert(false, `unexpected failure: ${e}`); + } + }); }); diff --git a/test/gopls/telemetry.test.ts b/test/gopls/telemetry.test.ts new file mode 100644 index 0000000000..d7a15c6ae5 --- /dev/null +++ b/test/gopls/telemetry.test.ts @@ -0,0 +1,159 @@ +/* eslint-disable node/no-unpublished-import */ +/*--------------------------------------------------------- + * Copyright 2023 The Go Authors. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------*/ + +import * as sinon from 'sinon'; +import { describe, it } from 'mocha'; +import { GOPLS_MAYBE_PROMPT_FOR_TELEMETRY, TELEMETRY_START_TIME_KEY, TelemetryService } from '../../src/goTelemetry'; +import { MockMemento } from '../mocks/MockMemento'; + +describe('# prompt for telemetry', () => { + it( + 'do not prompt if language client is not used', + testTelemetryPrompt( + { + noLangClient: true, + previewExtension: true, + samplingInterval: 1000 + }, + false + ) + ); // no crash when there is no language client. + it( + 'do not prompt if gopls does not support telemetry', + testTelemetryPrompt( + { + goplsWithoutTelemetry: true, + previewExtension: true, + samplingInterval: 1000 + }, + false + ) + ); + it( + 'prompt when telemetry started a while ago', + testTelemetryPrompt( + { + firstDate: new Date('2022-01-01'), + samplingInterval: 1000 + }, + true + ) + ); + it( + 'do not prompt if telemetry started two days ago', + testTelemetryPrompt( + { + firstDate: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000), // two days ago! + samplingInterval: 1000 + }, + false + ) + ); + it( + 'do not prompt if gopls with telemetry never ran', + testTelemetryPrompt( + { + firstDate: undefined, // gopls with telemetry not seen before. + samplingInterval: 1000 + }, + false + ) + ); + it( + 'do not prompt if not sampled', + testTelemetryPrompt( + { + firstDate: new Date('2022-01-01'), + samplingInterval: 0 + }, + false + ) + ); + it( + 'prompt only if sampled (machineID = 0, samplingInterval = 1)', + testTelemetryPrompt( + { + firstDate: new Date('2022-01-01'), + samplingInterval: 1, + hashMachineID: 0 + }, + true + ) + ); + it( + 'prompt only if sampled (machineID = 1, samplingInterval = 1)', + testTelemetryPrompt( + { + firstDate: new Date('2022-01-01'), + samplingInterval: 1, + hashMachineID: 1 + }, + false + ) + ); + it( + 'prompt all preview extension users', + testTelemetryPrompt( + { + firstDate: new Date('2022-01-01'), + previewExtension: true, + samplingInterval: 0 + }, + true + ) + ); + it( + 'do not prompt if vscode telemetry is disabled', + testTelemetryPrompt( + { + firstDate: new Date('2022-01-01'), + vsTelemetryDisabled: true, + previewExtension: true, + samplingInterval: 1000 + }, + false + ) + ); +}); + +interface testCase { + noLangClient?: boolean; // gopls is not running. + goplsWithoutTelemetry?: boolean; // gopls is too old. + firstDate?: Date; // first date the extension observed gopls with telemetry feature. + previewExtension?: boolean; // assume we are in dev/nightly extension. + vsTelemetryDisabled?: boolean; // assume the user disabled vscode general telemetry. + samplingInterval: number; // N where N out of 1000 are sampled. + hashMachineID?: number; // stub the machine id hash computation function. +} + +function testTelemetryPrompt(tc: testCase, wantPrompt: boolean) { + return async () => { + const languageClient = { + sendRequest: () => { + return Promise.resolve(); + } + }; + const spy = sinon.spy(languageClient, 'sendRequest'); + const lc = tc.noLangClient ? undefined : languageClient; + + const memento = new MockMemento(); + if (tc.firstDate) { + memento.update(TELEMETRY_START_TIME_KEY, tc.firstDate); + } + const commands = tc.goplsWithoutTelemetry ? [] : [GOPLS_MAYBE_PROMPT_FOR_TELEMETRY]; + + const sut = new TelemetryService(lc, memento, commands); + if (tc.hashMachineID !== undefined) { + sinon.stub(sut, 'hashMachineID').returns(tc.hashMachineID); + } + await sut.promptForTelemetry(!!tc.previewExtension, !tc.vsTelemetryDisabled, tc.samplingInterval); + if (wantPrompt) { + sinon.assert.calledOnce(spy); + } else { + sinon.assert.neverCalledWith(spy); + } + }; +}