Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 3 additions & 25 deletions src/common/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,17 +222,6 @@ export class BaseTelemetryReporter extends Disposable {
return ret;
}

/**
* Whether or not it is safe to send error telemetry
*/
private shouldSendErrorTelemetry(): boolean {
if (this.errorOptIn === false) {
return false;
}

return true;
}

// __GDPR__COMMON__ "common.os" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
// __GDPR__COMMON__ "common.nodeArch" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
// __GDPR__COMMON__ "common.platformversion" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
Expand Down Expand Up @@ -411,25 +400,14 @@ export class BaseTelemetryReporter extends Disposable {
* Given an event name, some properties, and measurements sends an error event
* @param eventName The name of the event
* @param properties The properties to send with the event
* @param errorProps If not present then we assume all properties belong to the error prop and will be anonymized
*/
public sendTelemetryErrorEvent(eventName: string, properties?: { [key: string]: string }, errorProps?: string[]): void {
public sendTelemetryErrorEvent(eventName: string, properties?: { [key: string]: string }): void {
if (this.errorOptIn && eventName !== '') {
// always clean the properties if first party
// do not send any error properties if we shouldn't send error telemetry
// if we have no errorProps, assume all are error props
properties = { ...properties, ...this.getCommonProperties() };
const cleanProperties = this.cloneAndChange(properties, (key: string, prop: string) => {
if (this.shouldSendErrorTelemetry()) {
return this.anonymizeFilePaths(prop, false);
}

if (errorProps === undefined || errorProps.indexOf(key) !== -1) {
return 'REDACTED';
}

return this.anonymizeFilePaths(prop, false);
});
const cleanProperties = this.cloneAndChange(properties, (_key: string, prop: string) => this.anonymizeFilePaths(prop, false));
this.telemetryAppender.logEvent(`${eventName}`, { properties: this.removePropertiesWithPossibleUserInfo(cleanProperties) });
}
}
Expand All @@ -440,7 +418,7 @@ export class BaseTelemetryReporter extends Disposable {
* @param properties The properties to send with the event
*/
public sendTelemetryException(error: Error, properties?: TelemetryEventProperties): void {
if (this.shouldSendErrorTelemetry() && this.errorOptIn && error) {
if (this.errorOptIn && error) {
properties = { ...properties, ...this.getCommonProperties() };
const cleanProperties = this.cloneAndChange(properties, (_key: string, prop: string) => this.anonymizeFilePaths(prop, false));
// Also run the error stack through the anonymizer
Expand Down
16 changes: 9 additions & 7 deletions src/heartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class HeartbeatManager extends Disposable {
readonly workspaceId: string,
readonly instanceId: string,
readonly debugWorkspace: boolean,
private readonly accessToken: string,
private readonly session: vscode.AuthenticationSession,
private readonly publicApi: GitpodPublicApi | undefined,
private readonly logger: vscode.LogOutputChannel,
private readonly telemetry: TelemetryReporter
Expand Down Expand Up @@ -90,7 +90,7 @@ export class HeartbeatManager extends Disposable {
}
}));

this.logger.debug(`Heartbeat manager for workspace ${workspaceId} (${instanceId}) - ${gitpodHost} started`);
this.logger.info(`Heartbeat manager for workspace ${workspaceId} (${instanceId}) - ${gitpodHost} started`);

// Start heartbeating interval
this.sendHeartBeat();
Expand All @@ -109,8 +109,8 @@ export class HeartbeatManager extends Disposable {
}

private updateLastActivity(event: string, document?: vscode.TextDocument) {
this.lastActivity = new Date().getTime();
this.lastActivityEvent = event;
this.lastActivity = new Date().getTime();
this.lastActivityEvent = event;

const eventName = document ? `${event}:${document.uri.scheme}` : event;

Expand All @@ -120,7 +120,7 @@ export class HeartbeatManager extends Disposable {

private async sendHeartBeat(wasClosed?: true) {
try {
await withServerApi(this.accessToken, this.gitpodHost, async service => {
await withServerApi(this.session.accessToken, this.gitpodHost, async service => {
const workspaceInfo = this.publicApi
? await this.publicApi.getWorkspace(this.workspaceId)
: await service.server.getWorkspace(this.workspaceId);
Expand All @@ -142,9 +142,11 @@ export class HeartbeatManager extends Disposable {
this.stopHeartbeat();
}
}, this.logger);
} catch (err) {
} catch (e) {
const suffix = wasClosed ? 'closed heartbeat' : 'heartbeat';
this.logger.error(`Failed to send ${suffix}, triggered by ${this.lastActivityEvent} event:`, err);
e.message = `Failed to send ${suffix}, triggered by event: ${this.lastActivityEvent}: ${e.message}`;
this.logger.error(e);
this.telemetry.sendTelemetryException(e, { workspaceId: this.workspaceId, instanceId: this.instanceId, userId: this.session.account.id });
}
}

Expand Down
118 changes: 56 additions & 62 deletions src/remoteConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ interface SSHConnectionParams {
}

interface SSHConnectionInfo extends SSHConnectionParams {
isFirstConnection: boolean;
}

interface WorkspaceRestartInfo {
Expand Down Expand Up @@ -144,12 +143,13 @@ export default class RemoteConnector extends Disposable {
) {
super();

if (isGitpodRemoteWindow(context)) {
const remoteConnectionInfo = getGitpodRemoteWindow(context);
if (remoteConnectionInfo) {
this._register(vscode.commands.registerCommand('gitpod.api.autoTunnel', this.autoTunnelCommand, this));

// Don't await this on purpose so it doesn't block extension activation.
// Internally requesting a Gitpod Session requires the extension to be already activated.
this.onGitpodRemoteConnection();
this.onGitpodRemoteConnection(remoteConnectionInfo);
} else {
this.checkForStoppedWorkspaces();
}
Expand Down Expand Up @@ -833,7 +833,7 @@ export default class RemoteConnector extends Disposable {

await this.updateRemoteSSHConfig(usingSSHGateway, localAppSSHConfigPath);

await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestination!.toRemoteSSHString()}`, { ...params, isFirstConnection: true } as SSHConnectionParams);
await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestination!.toRemoteSSHString()}`, { ...params } as SSHConnectionParams);

const forceNewWindow = this.context.extensionMode === vscode.ExtensionMode.Production;

Expand Down Expand Up @@ -888,7 +888,7 @@ export default class RemoteConnector extends Disposable {
return;
}

this.heartbeatManager = new HeartbeatManager(connectionInfo.gitpodHost, connectionInfo.workspaceId, connectionInfo.instanceId, !!connectionInfo.debugWorkspace, session.accessToken, this.publicApi, this.logger, this.telemetry);
this.heartbeatManager = new HeartbeatManager(connectionInfo.gitpodHost, connectionInfo.workspaceId, connectionInfo.instanceId, !!connectionInfo.debugWorkspace, session, this.publicApi, this.logger, this.telemetry);

try {
// TODO: remove this in the future, gitpod-remote no longer has the heartbeat logic, it's just here until users
Expand Down Expand Up @@ -1016,70 +1016,63 @@ export default class RemoteConnector extends Disposable {
}
}

private async onGitpodRemoteConnection() {
const remoteUri = vscode.workspace.workspaceFile || vscode.workspace.workspaceFolders?.[0].uri;
if (!remoteUri) {
return;
}
private async onGitpodRemoteConnection({ remoteAuthority, connectionInfo }: { remoteAuthority: string; connectionInfo: SSHConnectionInfo }) {
let session: vscode.AuthenticationSession | undefined;
try {
session = await this.getGitpodSession(connectionInfo.gitpodHost);
if (!session) {
throw new Error('No Gitpod session available');
}

const [, sshDestStr] = remoteUri.authority.split('+');
const connectionInfo = this.context.globalState.get<SSHConnectionInfo>(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`);
if (!connectionInfo) {
return;
}
const workspaceInfo = await withServerApi(session.accessToken, connectionInfo.gitpodHost, service => service.server.getWorkspace(connectionInfo.workspaceId), this.logger);
if (workspaceInfo.latestInstance?.status?.phase !== 'running') {
throw new NoRunningInstanceError(connectionInfo.workspaceId);
}

const session = await this.getGitpodSession(connectionInfo.gitpodHost);
if (!session) {
return;
}
if (workspaceInfo.latestInstance.id !== connectionInfo.instanceId) {
this.logger.info(`Updating workspace ${connectionInfo.workspaceId} latest instance id ${connectionInfo.instanceId} => ${workspaceInfo.latestInstance.id}`);
connectionInfo.instanceId = workspaceInfo.latestInstance.id;
}

const workspaceInfo = await withServerApi(session.accessToken, connectionInfo.gitpodHost, service => service.server.getWorkspace(connectionInfo.workspaceId), this.logger);
if (workspaceInfo.latestInstance?.status?.phase !== 'running') {
return;
}
const [, sshDestStr] = remoteAuthority.split('+');
await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`, { ...connectionInfo } as SSHConnectionParams);

if (workspaceInfo.latestInstance.id !== connectionInfo.instanceId) {
this.logger.info(`Updating workspace ${connectionInfo.workspaceId} latest instance id ${connectionInfo.instanceId} => ${workspaceInfo.latestInstance.id}`);
connectionInfo.instanceId = workspaceInfo.latestInstance.id;
}
const gitpodVersion = await getGitpodVersion(connectionInfo.gitpodHost, this.logger);

await this.context.globalState.update(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`, { ...connectionInfo, isFirstConnection: false } as SSHConnectionParams);
await this.initPublicApi(session, connectionInfo.gitpodHost);

const gitpodVersion = await getGitpodVersion(connectionInfo.gitpodHost, this.logger);
if (this.publicApi) {
this.workspaceState = new WorkspaceState(connectionInfo.workspaceId, this.publicApi, this.logger);

await this.initPublicApi(session, connectionInfo.gitpodHost);
let handled = false;
this._register(this.workspaceState.onWorkspaceStatusChanged(async () => {
if (!this.workspaceState!.isWorkspaceRunning() && !handled) {
handled = true;
await this.context.globalState.update(`${RemoteConnector.WORKSPACE_STOPPED_PREFIX}${connectionInfo.workspaceId}`, { workspaceId: connectionInfo.workspaceId, gitpodHost: connectionInfo.gitpodHost } as WorkspaceRestartInfo);
vscode.commands.executeCommand('workbench.action.remote.close');
}
}));
}

if (this.publicApi) {
this.workspaceState = new WorkspaceState(connectionInfo.workspaceId, this.publicApi, this.logger);

let handled = false;
this._register(this.workspaceState.onWorkspaceStatusChanged(async () => {
if (!this.workspaceState!.isWorkspaceRunning() && !handled) {
handled = true;
await this.context.globalState.update(`${RemoteConnector.WORKSPACE_STOPPED_PREFIX}${connectionInfo.workspaceId}`, { workspaceId: connectionInfo.workspaceId, gitpodHost: connectionInfo.gitpodHost } as WorkspaceRestartInfo);
vscode.commands.executeCommand('workbench.action.remote.close');
}
const heartbeatSupported = session.scopes.includes(ScopeFeature.LocalHeartbeat);
if (heartbeatSupported) {
this.startHeartBeat(session, connectionInfo);
} else {
this.logger.warn(`Local heartbeat not supported in ${connectionInfo.gitpodHost}, using version ${gitpodVersion.raw}`);
}

const syncExtFlow = { ...connectionInfo, gitpodVersion: gitpodVersion.raw, userId: session.account.id, flow: 'sync_local_extensions' };
this.initializeRemoteExtensions({ ...syncExtFlow, quiet: true, flowId: uuid() });
this.context.subscriptions.push(vscode.commands.registerCommand('gitpod.installLocalExtensions', () => {
this.initializeRemoteExtensions({ ...syncExtFlow, quiet: false, flowId: uuid() });
}));
}

const heartbeatSupported = session.scopes.includes(ScopeFeature.LocalHeartbeat);
if (heartbeatSupported) {
this.startHeartBeat(session, connectionInfo);
} else {
this.logger.warn(`Local heartbeat not supported in ${connectionInfo.gitpodHost}, using version ${gitpodVersion.raw}`);
vscode.commands.executeCommand('setContext', 'gitpod.inWorkspace', true);
} catch (e) {
e.message = `Failed to resolve whole gitpod remote connection process: ${e.message}`;
this.logger.error(e);
this.telemetry.sendTelemetryException(e, { workspaceId: connectionInfo.workspaceId, instanceId: connectionInfo.instanceId, userId: session?.account.id || '' });
}

const syncExtFlow = { ...connectionInfo, gitpodVersion: gitpodVersion.raw, userId: session.account.id, flow: 'sync_local_extensions' };
this.initializeRemoteExtensions({ ...syncExtFlow, quiet: true, flowId: uuid() });
this.context.subscriptions.push(vscode.commands.registerCommand('gitpod.installLocalExtensions', () => {
this.initializeRemoteExtensions({ ...syncExtFlow, quiet: false, flowId: uuid() });
}));

this._register(vscode.commands.registerCommand('__gitpod.workspaceShutdown', () => {
this.logger.warn('__gitpod.workspaceShutdown command executed');
}));

vscode.commands.executeCommand('setContext', 'gitpod.inWorkspace', true);
}

private async showWsNotRunningDialog(workspaceId: string, workspaceUrl: string, flow: UserFlowTelemetry) {
Expand Down Expand Up @@ -1125,16 +1118,17 @@ export default class RemoteConnector extends Disposable {
}
}

function isGitpodRemoteWindow(context: vscode.ExtensionContext) {
function getGitpodRemoteWindow(context: vscode.ExtensionContext): { remoteAuthority: string; connectionInfo: SSHConnectionInfo } | undefined {
const remoteUri = vscode.workspace.workspaceFile || vscode.workspace.workspaceFolders?.[0].uri;
if (vscode.env.remoteName === 'ssh-remote' && context.extension.extensionKind === vscode.ExtensionKind.UI && remoteUri) {
const [, sshDestStr] = remoteUri.authority.split('+');
const connectionInfo = context.globalState.get<SSHConnectionInfo>(`${RemoteConnector.SSH_DEST_KEY}${sshDestStr}`);

return !!connectionInfo;
if (connectionInfo) {
return { remoteAuthority: remoteUri.authority, connectionInfo };
}
}

return false;
return undefined;
}

function getServiceURL(gitpodHost: string): string {
Expand Down
46 changes: 42 additions & 4 deletions src/telemetryReporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,55 @@ const analyticsClientFactory = async (key: string): Promise<BaseTelemetryClient>
properties: data?.properties
});
} catch (e: any) {
throw new Error('Failed to log event to app analytics!\n' + e.message);
console.error('Failed to log event to app analytics!', e);
}
},
logException: (_exception: Error, _data?: AppenderData) => {
throw new Error('Failed to log exception to app analytics!\n');
logException: (exception: Error, data?: AppenderData) => {
const gitpodHost = vscode.workspace.getConfiguration('gitpod').get<string>('host')!;
const serviceUrl = new URL(gitpodHost);
const errorMetricsEndpoint = `https://ide.${serviceUrl.hostname}/metrics-api/reportError`;

const properties: { [key: string]: any } = Object.assign({}, data?.properties);
properties['error_name'] = exception.name;
properties['error_message'] = exception.message;
properties['debug_workspace'] = String(properties['debug_workspace'] ?? false);

const workspaceId = properties['workspaceId'] ?? '';
const instanceId = properties['instanceId'] ?? '';
const userId = properties['userId'] ?? '';

delete properties['workspaceId'];
delete properties['instanceId'];
delete properties['userId'];

const jsonData = {
component: 'vscode-desktop-extension',
errorStack: exception.stack ?? String(exception),
version: properties['common.extversion'],
workspaceId,
instanceId,
userId,
properties,
};
fetch(errorMetricsEndpoint, {
method: 'POST',
body: JSON.stringify(jsonData),
headers: {
'Content-Type': 'application/json',
},
}).then((resp) => {
if (!resp.ok) {
console.log(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`);
}
}).catch((e) => {
console.error('Failed to report error to metrics endpoint!', e);
});
},
flush: async () => {
try {
await segmentAnalyticsClient.flush();
} catch (e: any) {
throw new Error('Failed to flush app analytics!\n' + e.message);
console.error('Failed to flush app analytics!', e);
}
}
};
Expand Down