Skip to content

Commit 5c31613

Browse files
akosyakovjeanp413
andauthored
flow analytics against vscode apis (#21)
Co-authored-by: Jean Pierre <[email protected]>
1 parent 1244061 commit 5c31613

File tree

8 files changed

+229
-100
lines changed

8 files changed

+229
-100
lines changed

src/authentication.ts

Lines changed: 39 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import Log from './common/logger';
1212
import { arrayEquals } from './common/utils';
1313
import { Disposable } from './common/dispose';
1414
import TelemetryReporter from './telemetryReporter';
15+
import { UserFlowTelemetry } from './common/telemetry';
16+
import { NotificationService } from './notification';
1517

1618
interface SessionData {
1719
id: string;
@@ -28,35 +30,30 @@ export default class GitpodAuthenticationProvider extends Disposable implements
2830
private _sessionChangeEmitter = new vscode.EventEmitter<vscode.AuthenticationProviderAuthenticationSessionsChangeEvent>();
2931
private _logger: Log;
3032
private _telemetry: TelemetryReporter;
31-
private _gitpodServer: GitpodServer;
32-
private _keychain: Keychain;
3333

34-
private _serviceUrl: string;
34+
private _gitpodServer!: GitpodServer;
35+
private _keychain!: Keychain;
36+
private _serviceUrl!: string;
3537

3638
private _sessionsPromise: Promise<vscode.AuthenticationSession[]>;
3739

38-
constructor(private readonly context: vscode.ExtensionContext, logger: Log, telemetry: TelemetryReporter) {
40+
private readonly flow: Readonly<UserFlowTelemetry> = { flow: 'auth' };
41+
42+
constructor(
43+
private readonly context: vscode.ExtensionContext,
44+
logger: Log,
45+
telemetry: TelemetryReporter,
46+
private readonly notifications: NotificationService
47+
) {
3948
super();
4049

4150
this._logger = logger;
4251
this._telemetry = telemetry;
4352

44-
const gitpodHost = vscode.workspace.getConfiguration('gitpod').get<string>('host')!;
45-
const gitpodHostUrl = new URL(gitpodHost);
46-
this._serviceUrl = gitpodHostUrl.toString().replace(/\/$/, '');
47-
this._gitpodServer = new GitpodServer(this._serviceUrl, this._logger);
48-
this._keychain = new Keychain(this.context, `gitpod.auth.${gitpodHostUrl.hostname}`, this._logger);
49-
this._logger.info(`Started authentication provider for ${gitpodHost}`);
53+
this.reconcile();
5054
this._register(vscode.workspace.onDidChangeConfiguration(e => {
5155
if (e.affectsConfiguration('gitpod.host')) {
52-
const gitpodHost = vscode.workspace.getConfiguration('gitpod').get<string>('host')!;
53-
const gitpodHostUrl = new URL(gitpodHost);
54-
this._serviceUrl = gitpodHostUrl.toString().replace(/\/$/, '');
55-
this._gitpodServer.dispose();
56-
this._gitpodServer = new GitpodServer(this._serviceUrl, this._logger);
57-
this._keychain = new Keychain(this.context, `gitpod.auth.${gitpodHostUrl.hostname}`, this._logger);
58-
this._logger.info(`Started authentication provider for ${gitpodHost}`);
59-
56+
this.reconcile();
6057
this.checkForUpdates();
6158
}
6259
}));
@@ -68,6 +65,17 @@ export default class GitpodAuthenticationProvider extends Disposable implements
6865
this._register(this.context.secrets.onDidChange(() => this.checkForUpdates()));
6966
}
7067

68+
private reconcile(): void {
69+
const gitpodHost = vscode.workspace.getConfiguration('gitpod').get<string>('host')!;
70+
const gitpodHostUrl = new URL(gitpodHost);
71+
this._serviceUrl = gitpodHostUrl.toString().replace(/\/$/, '');
72+
Object.assign(this.flow, { gitpodHost: this._serviceUrl });
73+
this._gitpodServer?.dispose();
74+
this._gitpodServer = new GitpodServer(this._serviceUrl, this._logger, this.notifications);
75+
this._keychain = new Keychain(this.context, `gitpod.auth.${gitpodHostUrl.hostname}`, this._logger);
76+
this._logger.info(`Started authentication provider for ${gitpodHost}`);
77+
}
78+
7179
get onDidChangeSessions() {
7280
return this._sessionChangeEmitter.event;
7381
}
@@ -213,6 +221,7 @@ export default class GitpodAuthenticationProvider extends Disposable implements
213221
}
214222

215223
public async createSession(scopes: string[]): Promise<vscode.AuthenticationSession> {
224+
const flow = { ...this.flow };
216225
try {
217226
// For the Gitpod scope list, order doesn't matter so we immediately sort the scopes
218227
const sortedScopes = scopes.sort();
@@ -221,15 +230,13 @@ export default class GitpodAuthenticationProvider extends Disposable implements
221230
if (sortedScopes.length !== sortedFilteredScopes.length) {
222231
this._logger.warn(`Creating session with only valid scopes ${sortedFilteredScopes.join(',')}, original scopes were ${sortedScopes.join(',')}`);
223232
}
224-
225-
this._telemetry.sendRawTelemetryEvent('gitpod_desktop_auth', {
226-
kind: 'login',
227-
scopes: JSON.stringify(sortedFilteredScopes),
228-
});
233+
flow.scopes = JSON.stringify(sortedFilteredScopes);
234+
this._telemetry.sendUserFlowStatus('login', flow);
229235

230236
const scopeString = sortedFilteredScopes.join(' ');
231-
const token = await this._gitpodServer.login(scopeString);
237+
const token = await this._gitpodServer.login(scopeString, flow);
232238
const session = await this.tokenToSession(token, sortedFilteredScopes);
239+
flow.userId = session.account.id;
233240

234241
const sessions = await this._sessionsPromise;
235242
const sessionIndex = sessions.findIndex(s => s.id === session.id || arrayEquals([...s.scopes].sort(), sortedFilteredScopes));
@@ -244,19 +251,16 @@ export default class GitpodAuthenticationProvider extends Disposable implements
244251

245252
this._logger.info('Login success!');
246253

247-
this._telemetry.sendRawTelemetryEvent('gitpod_desktop_auth', { kind: 'login_successful' });
254+
this._telemetry.sendUserFlowStatus('login_successful', flow);
248255

249256
return session;
250257
} catch (e) {
251258
// If login was cancelled, do not notify user.
252259
if (e === 'Cancelled' || e.message === 'Cancelled') {
253-
this._telemetry.sendRawTelemetryEvent('gitpod_desktop_auth', { kind: 'login_cancelled' });
260+
this._telemetry.sendUserFlowStatus('login_cancelled', flow);
254261
throw e;
255262
}
256-
257-
this._telemetry.sendRawTelemetryEvent('gitpod_desktop_auth', { kind: 'login_failed' });
258-
259-
vscode.window.showErrorMessage(`Sign in failed: ${e}`);
263+
this.notifications.showErrorMessage(`Sign in failed: ${e}`, { flow, id: 'login_failed' });
260264
this._logger.error(e);
261265
throw e;
262266
}
@@ -273,15 +277,16 @@ export default class GitpodAuthenticationProvider extends Disposable implements
273277
}
274278

275279
public async removeSession(id: string) {
280+
const flow = { ...this.flow };
276281
try {
277-
this._telemetry.sendRawTelemetryEvent('gitpod_desktop_auth', { kind: 'logout' });
278-
282+
this._telemetry.sendUserFlowStatus('logout', flow);
279283
this._logger.info(`Logging out of ${id}`);
280284

281285
const sessions = await this._sessionsPromise;
282286
const sessionIndex = sessions.findIndex(session => session.id === id);
283287
if (sessionIndex > -1) {
284288
const session = sessions[sessionIndex];
289+
flow.userId = session.account.id;
285290
sessions.splice(sessionIndex, 1);
286291

287292
await this.storeSessions(sessions);
@@ -290,10 +295,9 @@ export default class GitpodAuthenticationProvider extends Disposable implements
290295
} else {
291296
this._logger.error('Session not found');
292297
}
298+
this._telemetry.sendUserFlowStatus('logout_successful', flow);
293299
} catch (e) {
294-
this._telemetry.sendRawTelemetryEvent('gitpod_desktop_auth', { kind: 'logout_failed' });
295-
296-
vscode.window.showErrorMessage(`Sign out failed: ${e}`);
300+
this.notifications.showErrorMessage(`Sign out failed: ${e}`, { flow, id: 'logout_failed' });
297301
this._logger.error(e);
298302
throw e;
299303
}

src/common/telemetry.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@
88
import * as vscode from 'vscode';
99
import { Disposable } from './dispose';
1010

11+
export interface TelemetryOptions {
12+
gitpodHost?: string;
13+
gitpodVersion?: string;
14+
15+
workspaceId?: string;
16+
instanceId?: string;
17+
18+
userId?: string
19+
20+
[prop: string]: any
21+
}
22+
23+
export interface UserFlowTelemetry extends TelemetryOptions {
24+
flow: string
25+
}
26+
1127
const enum TelemetryLevel {
1228
ON = 'on',
1329
ERROR = 'error',
@@ -373,6 +389,12 @@ export class BaseTelemetryReporter extends Disposable {
373389
}
374390
}
375391

392+
sendUserFlowStatus(status: string, flow: UserFlowTelemetry): void {
393+
const properties: TelemetryOptions = { ...flow, status };
394+
delete properties['flow'];
395+
this.sendRawTelemetryEvent('vscode_desktop_' + flow.flow, properties);
396+
}
397+
376398
/**
377399
* Given an event name, some properties, and measurements sends a raw (unsanitized) telemetry event
378400
* @param eventName The name of the event

src/experiments.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import * as semver from 'semver';
1010
import Log from './common/logger';
1111

1212
const EXPERTIMENTAL_SETTINGS = [
13-
'gitpod.remote.useLocalApp'
13+
'gitpod.remote.useLocalApp',
14+
'gitpod.remote.syncExtensions'
1415
];
1516

1617
export class ExperimentalSettings {

src/extension.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@
55

66
import * as os from 'os';
77
import * as vscode from 'vscode';
8-
import Log from './common/logger';
98
import GitpodAuthenticationProvider from './authentication';
9+
import Log from './common/logger';
10+
import { UserFlowTelemetry } from './common/telemetry';
11+
import { ExperimentalSettings } from './experiments';
12+
import { exportLogs } from './exportLogs';
13+
import GitpodServer from './gitpodServer';
14+
import { NotificationService } from './notification';
15+
import { ReleaseNotes } from './releaseNotes';
1016
import RemoteConnector from './remoteConnector';
1117
import { SettingsSync } from './settingsSync';
12-
import GitpodServer from './gitpodServer';
1318
import TelemetryReporter from './telemetryReporter';
14-
import { exportLogs } from './exportLogs';
15-
import { ReleaseNotes } from './releaseNotes';
16-
import { ExperimentalSettings } from './experiments';
1719

1820
const FIRST_INSTALL_KEY = 'gitpod-desktop.firstInstall';
1921

@@ -34,22 +36,26 @@ export async function activate(context: vscode.ExtensionContext) {
3436
context.subscriptions.push(experiments);
3537

3638
telemetry = new TelemetryReporter(extensionId, packageJSON.version, packageJSON.segmentKey);
39+
const notifications = new NotificationService(telemetry);
3740

3841
context.subscriptions.push(vscode.commands.registerCommand('gitpod.exportLogs', async () => {
42+
const flow: UserFlowTelemetry = { flow: 'export_logs' };
43+
telemetry.sendUserFlowStatus('exporting', flow);
3944
try {
4045
await exportLogs(context);
46+
telemetry.sendUserFlowStatus('exported', flow);
4147
} catch (e) {
4248
const outputMessage = `Error exporting logs: ${e}`;
43-
vscode.window.showErrorMessage(outputMessage);
49+
notifications.showErrorMessage(outputMessage, { id: 'failed', flow });
4450
logger.error(outputMessage);
4551
}
4652
}));
4753

48-
const settingsSync = new SettingsSync(logger, telemetry);
54+
const settingsSync = new SettingsSync(logger, telemetry, notifications);
4955
context.subscriptions.push(settingsSync);
5056

51-
const authProvider = new GitpodAuthenticationProvider(context, logger, telemetry);
52-
remoteConnector = new RemoteConnector(context, settingsSync, experiments, logger, telemetry);
57+
const authProvider = new GitpodAuthenticationProvider(context, logger, telemetry, notifications);
58+
remoteConnector = new RemoteConnector(context, settingsSync, experiments, logger, telemetry, notifications);
5359
context.subscriptions.push(authProvider);
5460
context.subscriptions.push(vscode.window.registerUriHandler({
5561
handleUri(uri: vscode.Uri) {

src/gitpodServer.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { withServerApi } from './internalApi';
1111
import pkceChallenge from 'pkce-challenge';
1212
import { v4 as uuid } from 'uuid';
1313
import { Disposable } from './common/dispose';
14+
import { NotificationService } from './notification';
15+
import { UserFlowTelemetry } from './common/telemetry';
1416

1517
interface ExchangeTokenResponse {
1618
token_type: 'Bearer';
@@ -38,13 +40,17 @@ export default class GitpodServer extends Disposable {
3840
private _codeExchangePromises = new Map<string, { promise: Promise<string>; cancel: vscode.EventEmitter<void> }>();
3941
private _uriEmitter = this._register(new vscode.EventEmitter<vscode.Uri>());
4042

41-
constructor(serviceUrl: string, private readonly _logger: Log) {
43+
constructor(
44+
serviceUrl: string,
45+
private readonly _logger: Log,
46+
private readonly notifications: NotificationService
47+
) {
4248
super();
4349

4450
this._serviceUrl = serviceUrl.replace(/\/$/, '');
4551
}
4652

47-
public async login(scopes: string): Promise<string> {
53+
public async login(scopes: string, flow: UserFlowTelemetry): Promise<string> {
4854
this._logger.info(`Logging in for the following scopes: ${scopes}`);
4955

5056
const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://gitpod.gitpod-desktop/complete-gitpod-auth`));
@@ -78,7 +84,7 @@ export default class GitpodServer extends Disposable {
7884
// before completing it.
7985
let codeExchangePromise = this._codeExchangePromises.get(scopes);
8086
if (!codeExchangePromise) {
81-
codeExchangePromise = promiseFromEvent(this._uriEmitter.event, this.exchangeCodeForToken(scopes));
87+
codeExchangePromise = promiseFromEvent(this._uriEmitter.event, this.exchangeCodeForToken(scopes, flow));
8288
this._codeExchangePromises.set(scopes, codeExchangePromise);
8389
}
8490

@@ -97,8 +103,8 @@ export default class GitpodServer extends Disposable {
97103
});
98104
}
99105

100-
private exchangeCodeForToken: (scopes: string) => PromiseAdapter<vscode.Uri, string> =
101-
(scopes) => async (uri, resolve, reject) => {
106+
private exchangeCodeForToken: (scopes: string, flow: UserFlowTelemetry) => PromiseAdapter<vscode.Uri, string> =
107+
(scopes, flow) => async (uri, resolve, reject) => {
102108
const query = new URLSearchParams(uri.query);
103109
const code = query.get('code');
104110
const state = query.get('state');
@@ -146,7 +152,7 @@ export default class GitpodServer extends Disposable {
146152
});
147153

148154
if (!exchangeTokenResponse.ok) {
149-
vscode.window.showErrorMessage(`Couldn't connect (token exchange): ${exchangeTokenResponse.statusText}, ${await exchangeTokenResponse.text()}`);
155+
this.notifications.showErrorMessage(`Couldn't connect (token exchange): ${exchangeTokenResponse.statusText}, ${await exchangeTokenResponse.text()}`, { flow, id: 'failed_to_exchange' });
150156
reject(exchangeTokenResponse.statusText);
151157
return;
152158
}
@@ -156,6 +162,7 @@ export default class GitpodServer extends Disposable {
156162
const accessToken = JSON.parse(Buffer.from(jwtToken.split('.')[1], 'base64').toString())['jti'];
157163
resolve(accessToken);
158164
} catch (err) {
165+
this.notifications.showErrorMessage(`Couldn't connect (token exchange): ${err}`, { flow, id: 'failed_to_exchange' });
159166
reject(err);
160167
}
161168
};

src/notification.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Gitpod. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import * as vscode from 'vscode';
7+
import { UserFlowTelemetry } from './common/telemetry';
8+
import TelemetryReporter from './telemetryReporter';
9+
10+
export interface NotificationOption extends vscode.MessageOptions {
11+
id: string
12+
flow: UserFlowTelemetry
13+
}
14+
15+
export class NotificationService {
16+
17+
constructor(
18+
private readonly telemetry: TelemetryReporter
19+
) { }
20+
21+
showInformationMessage<T extends vscode.MessageItem | string>(message: string, option: NotificationOption, ...items: T[]): Promise<T | undefined> {
22+
return this.withTelemetry<T>(option, 'info', () =>
23+
vscode.window.showInformationMessage(message, option, ...<any[]>items)
24+
);
25+
}
26+
27+
showWarningMessage<T extends vscode.MessageItem | string>(message: string, option: NotificationOption, ...items: T[]): Promise<T | undefined> {
28+
return this.withTelemetry<T>(option, 'warning', () =>
29+
vscode.window.showWarningMessage(message, option, ...<any[]>items)
30+
);
31+
}
32+
33+
showErrorMessage<T extends vscode.MessageItem | string>(message: string, option: NotificationOption, ...items: T[]): Promise<T | undefined> {
34+
return this.withTelemetry<T>(option, 'error', () =>
35+
vscode.window.showErrorMessage(message, option, ...<any[]>items)
36+
);
37+
}
38+
39+
private async withTelemetry<T extends vscode.MessageItem | string>(option: NotificationOption, severity: 'info' | 'warning' | 'error', cb: () => PromiseLike<T | undefined>): Promise<T | undefined> {
40+
const startTime = new Date().getTime();
41+
let element = option.id;
42+
if (option.modal === true) {
43+
element += '_modal';
44+
} else {
45+
element += '_notification';
46+
}
47+
const flowOptions = { ...option.flow, severity };
48+
this.telemetry.sendUserFlowStatus('show_' + element, flowOptions);
49+
let result: T | undefined;
50+
try {
51+
result = await cb();
52+
} finally {
53+
const duration = new Date().getTime() - startTime;
54+
const closed = result === undefined || (typeof result === 'object' && result.isCloseAffordance == true);
55+
const status = closed ? 'close_' + element : 'select_' + element + '_action';
56+
const action = typeof result === 'string' ? result : result?.title;
57+
this.telemetry.sendUserFlowStatus(status, { ...flowOptions, action, duration });
58+
}
59+
return result;
60+
}
61+
62+
}

0 commit comments

Comments
 (0)