diff --git a/package.json b/package.json index 8235627..391054f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "displayName": "Gitpod", "description": "Required to connect to Classic workspaces", "publisher": "gitpod", - "version": "0.0.182", + "version": "0.0.183", "license": "MIT", "icon": "resources/gitpod.png", "repository": { @@ -420,7 +420,7 @@ "@types/js-yaml": "^4.0.5", "@types/http-proxy-agent": "^2.0.1", "@types/mocha": "^9.1.1", - "@types/node": "18.x", + "@types/node": "20.x", "@types/proper-lockfile": "^4.1.2", "@types/semver": "^7.3.10", "@types/ssh2": "^0.5.52", @@ -442,7 +442,7 @@ "mocha": "^10.0.0", "ts-loader": "^9.2.7", "ts-proto": "^1.140.0", - "typescript": "^4.6.3", + "typescript": "^5.7.3", "webpack": "^5.42.0", "webpack-cli": "^4.7.2" }, diff --git a/src/authentication/authentication.ts b/src/authentication/authentication.ts index 0a1ee77..68d476c 100644 --- a/src/authentication/authentication.ts +++ b/src/authentication/authentication.ts @@ -13,6 +13,7 @@ import { ITelemetryService, UserFlowTelemetryProperties } from '../common/teleme import { INotificationService } from '../services/notificationService'; import { ILogService } from '../services/logService'; import { Configuration } from '../configuration'; +import { unwrapFetchError } from '../common/fetch'; interface SessionData { id: string; @@ -102,13 +103,21 @@ export default class GitpodAuthenticationProvider extends Disposable implements try { const controller = new AbortController(); setTimeout(() => controller.abort(), 1500); - const resp = await fetch(endpoint, { signal: controller.signal }); + + let resp: Response; + try { + resp = await fetch(endpoint, { signal: controller.signal }); + } catch (e) { + throw unwrapFetchError(e); + } + if (resp.ok) { this._validScopes = (await resp.json()) as string[]; return this._validScopes; } } catch (e) { - this.logService.error(`Error fetching endpoint ${endpoint}`, e); + this.logService.error(`Error fetching endpoint ${endpoint}`); + this.logService.error(e); } return undefined; } diff --git a/src/authentication/gitpodServer.ts b/src/authentication/gitpodServer.ts index fea7485..3415009 100644 --- a/src/authentication/gitpodServer.ts +++ b/src/authentication/gitpodServer.ts @@ -12,6 +12,7 @@ import { Disposable } from '../common/dispose'; import { INotificationService } from '../services/notificationService'; import { UserFlowTelemetryProperties } from '../common/telemetry'; import { ILogService } from '../services/logService'; +import { unwrapFetchError } from '../common/fetch'; interface ExchangeTokenResponse { token_type: 'Bearer'; @@ -150,16 +151,21 @@ export default class GitpodServer extends Disposable { const callbackUri = await vscode.env.asExternalUri(vscode.Uri.parse(`${vscode.env.uriScheme}://gitpod.gitpod-desktop${GitpodServer.AUTH_COMPLETE_PATH}`)); try { - const exchangeTokenResponse = await fetch(`${this._serviceUrl}/api/oauth/token`, { - method: 'POST', - body: new URLSearchParams({ - code, - grant_type: 'authorization_code', - client_id: `${vscode.env.uriScheme}-gitpod`, - redirect_uri: callbackUri.toString(true), - code_verifier: verifier - }) - }); + let exchangeTokenResponse:Response; + try { + exchangeTokenResponse = await fetch(`${this._serviceUrl}/api/oauth/token`, { + method: 'POST', + body: new URLSearchParams({ + code, + grant_type: 'authorization_code', + client_id: `${vscode.env.uriScheme}-gitpod`, + redirect_uri: callbackUri.toString(true), + code_verifier: verifier + }) + }); + } catch (e) { + throw unwrapFetchError(e); + } if (!exchangeTokenResponse.ok) { this.notificationService.showErrorMessage(`Couldn't connect (token exchange): ${exchangeTokenResponse.statusText}, ${await exchangeTokenResponse.text()}`, { flow, id: 'failed_to_exchange' }); @@ -172,6 +178,9 @@ export default class GitpodServer extends Disposable { const accessToken = JSON.parse(Buffer.from(jwtToken.split('.')[1], 'base64').toString())['jti']; resolve(accessToken); } catch (err) { + this.logService.error('Error exchanging code for token'); + this.logService.error(err); + this.notificationService.showErrorMessage(`Couldn't connect (token exchange): ${err}`, { flow, id: 'failed_to_exchange' }); reject(err); } diff --git a/src/common/fetch.ts b/src/common/fetch.ts new file mode 100644 index 0000000..589d0d8 --- /dev/null +++ b/src/common/fetch.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Gitpod. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Collect error messages from nested errors as seen with Node's `fetch`. +function collectFetchErrorMessages(e: any): string { + const seen = new Set(); + function collect(e: any, indent: string): string { + if (!e || typeof e !== 'object' || seen.has(e)) { + return ''; + } + seen.add(e); + const message = e.stack || e.message || e.code || e.toString?.() || ''; + const messageStr = message.toString?.() as (string | undefined) || ''; + return [ + messageStr ? `${messageStr.split('\n').map(line => `${indent}${line}`).join('\n')}\n` : '', + collect(e.cause, indent + ' '), + ...(Array.isArray(e.errors) ? e.errors.map((e: any) => collect(e, indent + ' ')) : []), + ].join(''); + } + return collect(e, '').trim(); +} + +export function unwrapFetchError(e: any) { + const err = new Error(); + // Put collected messaged in the stack so vscode logger prints it + err.stack = collectFetchErrorMessages(e); + err.cause = undefined; + err.message = 'fetch error'; + return err; +} diff --git a/src/common/metrics.ts b/src/common/metrics.ts index 2508bc1..c876089 100644 --- a/src/common/metrics.ts +++ b/src/common/metrics.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { ILogService } from '../services/logService'; +import { unwrapFetchError } from './fetch'; import { isBuiltFromGHA } from './utils'; -import fetch from 'node-fetch-commonjs'; const metricsHostMap = new Map(); @@ -20,17 +20,23 @@ export async function addCounter(gitpodHost: string, name: string, labels: Recor return; } const metricsHost = getMetricsHost(gitpodHost); - const resp = await fetch( - `https://${metricsHost}/metrics-api/metrics/counter/add/${name}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Client': 'vscode-desktop-extension' - }, - body: JSON.stringify(data) - } - ); + + let resp: Response; + try { + resp = await fetch( + `https://${metricsHost}/metrics-api/metrics/counter/add/${name}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client': 'vscode-desktop-extension' + }, + body: JSON.stringify(data) + } + ); + } catch (e) { + throw unwrapFetchError(e); + } if (!resp.ok) { throw new Error(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`); @@ -50,17 +56,23 @@ export async function addHistogram(gitpodHost: string, name: string, labels: Rec return; } const metricsHost = getMetricsHost(gitpodHost); - const resp = await fetch( - `https://${metricsHost}/metrics-api/metrics/histogram/add/${name}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Client': 'vscode-desktop-extension' - }, - body: JSON.stringify(data) - } - ); + + let resp: Response; + try { + resp = await fetch( + `https://${metricsHost}/metrics-api/metrics/histogram/add/${name}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Client': 'vscode-desktop-extension' + }, + body: JSON.stringify(data) + } + ); + } catch (e) { + throw unwrapFetchError(e); + } if (!resp.ok) { throw new Error(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`); diff --git a/src/common/telemetry.ts b/src/common/telemetry.ts index 1925a5f..7d0da6f 100644 --- a/src/common/telemetry.ts +++ b/src/common/telemetry.ts @@ -7,7 +7,7 @@ import * as os from 'os'; import { Analytics, AnalyticsSettings } from '@segment/analytics-node'; import { ILogService } from '../services/logService'; import { cloneAndChange, escapeRegExpCharacters, isBuiltFromGHA, mixin } from '../common/utils'; -import fetch from 'node-fetch-commonjs'; +import { unwrapFetchError } from './fetch'; export const TRUSTED_VALUES = new Set([ 'gitpodHost', @@ -63,16 +63,6 @@ export function createSegmentAnalyticsClient(settings: AnalyticsSettings, gitpod return client; } - -function getErrorMetricsEndpoint(gitpodHost: string): string { - try { - const serviceUrl = new URL(gitpodHost); - return `https://ide.${serviceUrl.hostname}/metrics-api/reportError`; - } catch { - throw new Error(`Invalid URL: ${gitpodHost}`); - } -} - export async function commonSendEventData(logService: ILogService, segmentClient: Analytics | undefined, machineId: string, eventName: string, data?: any): Promise { const properties = data ?? {}; @@ -125,7 +115,14 @@ export function commonSendErrorData(logService: ILogService, defaultGitpodHost: // Unhandled errors have no data so use host from config const gitpodHost = properties['gitpodHost'] ?? defaultGitpodHost; - const errorMetricsEndpoint = getErrorMetricsEndpoint(gitpodHost); + let errorMetricsEndpoint: string; + try { + const serviceUrl = new URL(gitpodHost); + errorMetricsEndpoint = `https://ide.${serviceUrl.hostname}/metrics-api/reportError`; + } catch { + logService.error(`Invalid gitpodHost: ${gitpodHost}`); + return; + } properties['error_name'] = error.name; properties['error_message'] = errorProps.message; @@ -173,7 +170,9 @@ export function commonSendErrorData(logService: ILogService, defaultGitpodHost: logService.error(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`); } }).catch((e) => { - logService.error('Failed to report error to metrics endpoint!', e); + const err = unwrapFetchError(e); + logService.error('Failed to report error to metrics endpoint!'); + logService.error(err); }); } diff --git a/src/common/utils.ts b/src/common/utils.ts index d5f2442..4972417 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -150,7 +150,7 @@ export default function isPlainObject(value: any) { export class WrapError extends Error { constructor( msg: string, - readonly cause: any, + override readonly cause: any, readonly code?: string ) { super(); diff --git a/src/local-ssh/proxy.ts b/src/local-ssh/proxy.ts index 9420d36..689edad 100644 --- a/src/local-ssh/proxy.ts +++ b/src/local-ssh/proxy.ts @@ -209,7 +209,7 @@ class WebSocketSSHProxy { pipePromise = localSession.pipe(pipeSession); return {}; }).catch(async err => { - this.logService.error('failed to authenticate proxy with username: ' + e.username ?? '', err); + this.logService.error('failed to authenticate proxy with username: ' + (e.username ?? ''), err); this.flow.failureCode = getFailureCode(err); let sendErrorReport = true; diff --git a/src/metrics.ts b/src/metrics.ts index cbc8f61..1a600a7 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -205,7 +205,14 @@ export class MetricsReporter { if (this.intervalHandler) { return; } - this.intervalHandler = setInterval(() => this.report().catch(e => this.logger.error('Error while reporting metrics', e)), MetricsReporter.REPORT_INTERVAL); + this.intervalHandler = setInterval(async () => { + try { + await this.report() + } catch (e) { + this.logger.error('Error while reporting metrics'); + this.logger.error(e); + } + }, MetricsReporter.REPORT_INTERVAL); } private async report() { diff --git a/src/remoteConnector.ts b/src/remoteConnector.ts index 3708201..126a6b4 100644 --- a/src/remoteConnector.ts +++ b/src/remoteConnector.ts @@ -27,6 +27,7 @@ import { IHostService } from './services/hostService'; import { WrapError, getServiceURL } from './common/utils'; import { IRemoteService } from './services/remoteService'; import { ExportLogsCommand } from './commands/logs'; +import { unwrapFetchError } from './common/fetch'; export class RemoteConnector extends Disposable { @@ -69,7 +70,13 @@ export class RemoteConnector extends Disposable { const workspaceUrl = new URL((workspaceInfo as Workspace).status!.instance!.status!.url); const sshHostKeyEndPoint = `https://${workspaceUrl.host}/_ssh/host_keys`; - const sshHostKeyResponse = await fetch(sshHostKeyEndPoint); + let sshHostKeyResponse: Response; + try { + sshHostKeyResponse = await fetch(sshHostKeyEndPoint); + } catch (e) { + throw unwrapFetchError(e); + } + if (!sshHostKeyResponse.ok) { // Gitpod SSH gateway not configured throw new NoSSHGatewayError(gitpodHost); @@ -319,16 +326,19 @@ export class RemoteConnector extends Disposable { } this.telemetryService.sendUserFlowStatus('failed', { ...gatewayFlow, reason }); if (e instanceof NoRunningInstanceError) { - this.logService.error('No Running instance:', e); + this.logService.error('No Running instance:'); + this.logService.error(e); gatewayFlow['phase'] = e.phase; this.notificationService.showErrorMessage(`Failed to connect to ${e.workspaceId} Gitpod workspace: workspace not running`, { flow: gatewayFlow, id: 'no_running_instance' }); return undefined; } else { if (e instanceof SSHError) { - this.logService.error('SSH test connection error:', e); + this.logService.error('SSH test connection error:'); } else { - this.logService.error(`Failed to connect to ${params.workspaceId} Gitpod workspace:`, e); + this.logService.error(`Failed to connect to ${params.workspaceId} Gitpod workspace:`); } + this.logService.error(e); + const seeLogs = 'See Logs'; const showTroubleshooting = 'Show Troubleshooting'; this.notificationService.showErrorMessage(`Failed to connect to ${params.workspaceId} Gitpod workspace`, { flow: gatewayFlow, id: 'failed_to_connect' }, seeLogs, showTroubleshooting) diff --git a/src/services/remoteService.ts b/src/services/remoteService.ts index aaa24d5..62b4c53 100644 --- a/src/services/remoteService.ts +++ b/src/services/remoteService.ts @@ -31,6 +31,7 @@ import { INotificationService } from './notificationService'; import { getOpenSSHVersion } from '../ssh/nativeSSH'; import { retry } from '../common/async'; import { IStoredProfileExtension } from '../profileExtensions'; +import { unwrapFetchError } from '../common/fetch'; export interface IRemoteService { flow?: UserFlowTelemetryProperties; @@ -243,7 +244,13 @@ export class RemoteService extends Disposable implements IRemoteService { const wsUrl = new URL(workspaceUrl); const sshHostKeyEndPoint = `https://${wsUrl.host}/_ssh/host_keys`; - const sshHostKeyResponse = await fetch(sshHostKeyEndPoint); + let sshHostKeyResponse: Response; + try { + sshHostKeyResponse = await fetch(sshHostKeyEndPoint); + } catch (e) { + throw unwrapFetchError(e); + } + if (!sshHostKeyResponse.ok) { // Gitpod SSH gateway not configured throw new NoSSHGatewayError(this.hostService.gitpodHost); diff --git a/tsconfig.json b/tsconfig.json index 9868ed1..a18a5ea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,28 +1,8 @@ { "compilerOptions": { - "target": "es2021", + "target": "es2022", "lib": [ - "ES2016", - "ES2017.Object", - "ES2017.String", - "ES2017.Intl", - "ES2017.TypedArrays", - "ES2018.AsyncIterable", - "ES2018.AsyncGenerator", - "ES2018.Promise", - "ES2018.Regexp", - "ES2018.Intl", - "ES2019.Array", - "ES2019.Object", - "ES2019.String", - "ES2019.Symbol", - "ES2020.BigInt", - "ES2020.Promise", - "ES2020.String", - "ES2020.Symbol.WellKnown", - "ES2020.Intl", - "ES2021.Promise", - "ES2021.String", + "ES2022", "DOM" ], "module": "commonjs", diff --git a/yarn.lock b/yarn.lock index 3bf32fe..0739387 100644 --- a/yarn.lock +++ b/yarn.lock @@ -438,12 +438,12 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.35.tgz#635b7586086d51fb40de0a2ec9d1014a5283ba4a" integrity sha512-vu1SrqBjbbZ3J6vwY17jBs8Sr/BKA+/a/WtjRG+whKg1iuLFOosq872EXS0eXWILdO36DHQQeku/ZcL6hz2fpg== -"@types/node@18.x": - version "18.19.34" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.34.tgz#c3fae2bbbdb94b4a52fe2d229d0dccce02ef3d27" - integrity sha512-eXF4pfBNV5DAMKGbI02NnDtWrQ40hAN558/2vvS4gMpMIxaf6JmD7YjnZbq0Q9TDSSkKBamime8ewRoomHdt4g== +"@types/node@20.x": + version "20.17.32" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.32.tgz#cb9703514cd8e172c11beff582c66006644c2d88" + integrity sha512-zeMXFn8zQ+UkjK4ws0RiOC9EWByyW1CcVmLe+2rQocXRsGEDxUCwPEIVgpsGcLHS/P8JkT0oa3839BRABS0oPw== dependencies: - undici-types "~5.26.4" + undici-types "~6.19.2" "@types/node@>=12.12.47", "@types/node@>=13.7.0": version "18.14.2" @@ -4081,10 +4081,10 @@ typed-rest-client@^1.8.4: tunnel "0.0.6" underscore "^1.12.1" -typescript@^4.6.3: - version "4.7.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.2.tgz#1f9aa2ceb9af87cca227813b4310fff0b51593c4" - integrity sha512-Mamb1iX2FDUpcTRzltPxgWMKy3fhg0TN378ylbktPGPK/99KbDtMQ4W1hwgsbPAsG3a0xKa1vmw4VKZQbkvz5A== +typescript@^5.7.3: + version "5.8.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.6" @@ -4096,10 +4096,10 @@ underscore@^1.12.1: resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" integrity sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A== -undici-types@~5.26.4: - version "5.26.5" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" - integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== +undici-types@~6.19.2: + version "6.19.8" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" + integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== undici@^5.25.4: version "5.25.4"