Skip to content

Commit

Permalink
src/goTelemetry: ask gopls to prompt for telemetry opt-in
Browse files Browse the repository at this point in the history
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 <[email protected]>
Reviewed-by: Suzy Mueller <[email protected]>
TryBot-Result: kokoro <[email protected]>
Commit-Queue: Hyang-Ah Hana Kim <[email protected]>
  • Loading branch information
hyangah committed Oct 11, 2023
1 parent 75f9d50 commit 7e2e5ad
Show file tree
Hide file tree
Showing 6 changed files with 318 additions and 10 deletions.
7 changes: 7 additions & 0 deletions src/commands/startLanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
82 changes: 82 additions & 0 deletions src/goTelemetry.ts
Original file line number Diff line number Diff line change
@@ -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<LanguageClient, 'sendRequest'> | 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<Date>(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<Date>(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);
}
}
55 changes: 45 additions & 10 deletions src/language/goLanguageServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -309,6 +312,7 @@ const race = function (promise: Promise<unknown>, 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;

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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[]],
Expand Down Expand Up @@ -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();
}
23 changes: 23 additions & 0 deletions test/gopls/extension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
}
});
});
Loading

0 comments on commit 7e2e5ad

Please sign in to comment.