diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b2e483992b4d..ff25aaf68b670 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -287,7 +287,14 @@ "presentation": { "group": "0_vscode", "order": 2 - } + }, + "env": { + "VSCODE_DEV": "1", + "NODE_ENV": "development" + }, + "args": [ + "--without-connection-token" + ] }, { "type": "node", diff --git a/build/.webignore b/build/.webignore index 7441579719451..85377ba1cc8aa 100644 --- a/build/.webignore +++ b/build/.webignore @@ -45,6 +45,7 @@ xterm-addon-webgl/out/** @gitpod/** !@gitpod/local-app-api-grpcweb/lib/localapp.js +!@gitpod/ide-metrics-api-grpcweb/lib/index.js browser-headers/** google-protobuf/** diff --git a/doc/DEV.md b/doc/DEV.md new file mode 100644 index 0000000000000..575b31e3d814a --- /dev/null +++ b/doc/DEV.md @@ -0,0 +1,31 @@ +## Observability + +### Metrics defintion +- Declare new metrics or update existing in https://github.com/gitpod-io/gitpod/blob/ad355c4d9abd858a44daf15f9bd6747976142911/install/installer/pkg/components/ide-metrics/configmap.go +- Create a new branch and push, wait for https://werft.gitpod-dev.com/ to create a preview env. + +### Collecting metrics +- Convert VS Code telemetry to metrics in https://github.com/gitpod-io/openvscode-server/blob/63796b8c6eca9bcaf36b90ae1e96dae32638bab6/src/vs/gitpod/common/insightsHelper.ts#L35. + +### Testing from sources +- Add to product.json (don't commit!): +```jsonc +"gitpodPreview": { + "host": "", + // optionally to log to stdout or browser console + "log": { + "metrics": true, + "analytics": false, + } +} +``` +- Restart VS Code Server and open VS Code preview page to trigger telemetry events. +- In dev workspace for gitpod-io/gitpod run `./dev/preview/portforward-monitoring-satellite.sh -c harvester` +- Navigate to a printed Grafana link, open Explorer view, select prometheus as a data source and query for metrics. + +### Integration testing + +- Commit changes in this repo. +- Update codeCommit in WORKSPACE.yaml in gitpod-io/gitpod and push. +- Wait for https://werft.gitpod-dev.com/ to update preview envs. +- Test the complete integration. diff --git a/package.json b/package.json index 550d1f594e77a..a424b0e6b4c2a 100644 --- a/package.json +++ b/package.json @@ -62,8 +62,10 @@ }, "dependencies": { "@gitpod/gitpod-protocol": "main", + "@gitpod/ide-metrics-api-grpcweb": "ak-ext-metrics", "@gitpod/local-app-api-grpcweb": "main", "@gitpod/supervisor-api-grpc": "main", + "@improbable-eng/grpc-web-node-http-transport": "^0.15.0", "@microsoft/1ds-core-js": "^3.2.2", "@microsoft/1ds-post-js": "^3.2.2", "@parcel/watcher": "2.0.5", diff --git a/remote/package.json b/remote/package.json index f42eb6e070f1a..e95be1f4cdbf0 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,7 +4,9 @@ "private": true, "dependencies": { "@gitpod/gitpod-protocol": "main", + "@gitpod/ide-metrics-api-grpcweb": "ak-ext-metrics", "@gitpod/supervisor-api-grpc": "main", + "@improbable-eng/grpc-web-node-http-transport": "^0.15.0", "@microsoft/1ds-core-js": "^3.2.2", "@microsoft/1ds-post-js": "^3.2.2", "@parcel/watcher": "2.0.5", diff --git a/remote/web/package.json b/remote/web/package.json index e9f133b484240..aa547ac36cb58 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@gitpod/local-app-api-grpcweb": "main", + "@gitpod/ide-metrics-api-grpcweb": "ak-ext-metrics", "@microsoft/1ds-core-js": "^3.2.2", "@microsoft/1ds-post-js": "^3.2.2", "@vscode/iconv-lite-umd": "0.7.0", diff --git a/remote/web/yarn.lock b/remote/web/yarn.lock index 7a8a0769fe95f..d1b980dc6140e 100644 --- a/remote/web/yarn.lock +++ b/remote/web/yarn.lock @@ -2,6 +2,14 @@ # yarn lockfile v1 +"@gitpod/ide-metrics-api-grpcweb@ak-ext-metrics": + version "0.0.1-ak-ext-metrics.4" + resolved "https://registry.yarnpkg.com/@gitpod/ide-metrics-api-grpcweb/-/ide-metrics-api-grpcweb-0.0.1-ak-ext-metrics.4.tgz#9dacee7f13181e132fba9e4a5b97cb7f4b1739d2" + integrity sha512-s1C4W5Q7nlgyaQGCKqG8gI1ZdzwsaFZW2k8VX5hJKIcpD5S60I7AJbP1wVxYhRqR93YW3++DHydSpbjBUVnQFA== + dependencies: + "@improbable-eng/grpc-web" "^0.14.0" + google-protobuf "^3.19.1" + "@gitpod/local-app-api-grpcweb@main": version "0.1.5-main.1701" resolved "https://registry.yarnpkg.com/@gitpod/local-app-api-grpcweb/-/local-app-api-grpcweb-0.1.5-main.1701.tgz#3f4f4203c4532b098d697c65799095c3d4add9d4" @@ -93,6 +101,11 @@ google-protobuf@^3.17.0: resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.19.0.tgz#97f474323c92f19fd6737af1bb792e396991e0b8" integrity sha512-qXGAiv3OOlaJXJNeKOBKxbBAwjsxzhx+12ZdKOkZTsqsRkyiQRmr/nBkAkqnuQ8cmA9X5NVXvObQTpHVnXE2DQ== +google-protobuf@^3.19.1: + version "3.21.0" + resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.21.0.tgz#8dfa3fca16218618d373d414d3c1139e28034d6e" + integrity sha512-byR7MBTK4tZ5PZEb+u5ZTzpt4SfrTxv5682MjPlHN16XeqgZE2/8HOIWeiXe8JKnT9OVbtBGhbq8mtvkK8cd5g== + jschardet@3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/jschardet/-/jschardet-3.0.0.tgz#898d2332e45ebabbdb6bf2feece9feea9a99e882" diff --git a/remote/yarn.lock b/remote/yarn.lock index 7eaf5a455ce8c..ecd4adcdeaa69 100644 --- a/remote/yarn.lock +++ b/remote/yarn.lock @@ -36,6 +36,14 @@ vscode-ws-jsonrpc "^0.2.0" ws "^7.4.6" +"@gitpod/ide-metrics-api-grpcweb@ak-ext-metrics": + version "0.0.1-ak-ext-metrics.4" + resolved "https://registry.yarnpkg.com/@gitpod/ide-metrics-api-grpcweb/-/ide-metrics-api-grpcweb-0.0.1-ak-ext-metrics.4.tgz#9dacee7f13181e132fba9e4a5b97cb7f4b1739d2" + integrity sha512-s1C4W5Q7nlgyaQGCKqG8gI1ZdzwsaFZW2k8VX5hJKIcpD5S60I7AJbP1wVxYhRqR93YW3++DHydSpbjBUVnQFA== + dependencies: + "@improbable-eng/grpc-web" "^0.14.0" + google-protobuf "^3.19.1" + "@gitpod/supervisor-api-grpc@main": version "0.1.5-main.2046" resolved "https://registry.yarnpkg.com/@gitpod/supervisor-api-grpc/-/supervisor-api-grpc-0.1.5-main.2046.tgz#47b450cda80b3b655a76e2829a8eca66d70084bc" @@ -63,6 +71,18 @@ protobufjs "^6.10.0" yargs "^16.1.1" +"@improbable-eng/grpc-web-node-http-transport@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@improbable-eng/grpc-web-node-http-transport/-/grpc-web-node-http-transport-0.15.0.tgz#5a064472ef43489cbd075a91fb831c2abeb09d68" + integrity sha512-HLgJfVolGGpjc9DWPhmMmXJx8YGzkek7jcCFO1YYkSOoO81MWRZentPOd/JiKiZuU08wtc4BG+WNuGzsQB5jZA== + +"@improbable-eng/grpc-web@^0.14.0": + version "0.14.1" + resolved "https://registry.yarnpkg.com/@improbable-eng/grpc-web/-/grpc-web-0.14.1.tgz#f4662f64dc89c0f956a94bb8a3b576556c74589c" + integrity sha512-XaIYuunepPxoiGVLLHmlnVminUGzBTnXr8Wv7khzmLWbNw4TCwJKX09GSMJlKhu/TRk6gms0ySFxewaETSBqgw== + dependencies: + browser-headers "^0.4.1" + "@microsoft/1ds-core-js@3.2.3", "@microsoft/1ds-core-js@^3.2.2": version "3.2.3" resolved "https://registry.yarnpkg.com/@microsoft/1ds-core-js/-/1ds-core-js-3.2.3.tgz#2217d92ec8b073caa4577a13f40ea3a5c4c4d4e7" @@ -358,6 +378,11 @@ bluebird@^3.3.3: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +browser-headers@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/browser-headers/-/browser-headers-0.4.1.tgz#4308a7ad3b240f4203dbb45acedb38dc2d65dd02" + integrity sha512-CA9hsySZVo9371qEHjHZtYxV2cFtVj5Wj/ZHi8ooEsrtm4vOnl9Y9HmyYWk9q+05d7K3rdoAE0j3MVEFVvtQtg== + buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index 1ae8079810e8b..c42706241d7e7 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -156,6 +156,16 @@ export interface IProductConfiguration { readonly 'editSessions.store'?: Omit; readonly darwinUniversalAssetId?: string; + + readonly gitpodPreview?: IGitpodPreviewConfiguration; +} + +export interface IGitpodPreviewConfiguration { + host: string; + log?: { + analytics?: boolean; + metrics?: boolean; + }; } export type ImportantExtensionTip = { name: string; languages?: string[]; pattern?: string; isExtensionPack?: boolean; whenNotInstalled?: string[] }; diff --git a/src/vs/gitpod/browser/gitpodInsightsAppender.ts b/src/vs/gitpod/browser/gitpodInsightsAppender.ts index d1fb0958a239f..a4a28fc1ea26a 100644 --- a/src/vs/gitpod/browser/gitpodInsightsAppender.ts +++ b/src/vs/gitpod/browser/gitpodInsightsAppender.ts @@ -6,14 +6,17 @@ import { IProductService } from 'vs/platform/product/common/productService'; import { ITelemetryAppender } from 'vs/platform/telemetry/common/telemetryUtils'; -import { mapTelemetryData, SenderKind } from 'vs/gitpod/common/insightsHelper'; +import { mapMetrics, mapTelemetryData } from 'vs/gitpod/common/insightsHelper'; +import type { IDEMetric } from '@gitpod/ide-metrics-api-grpcweb'; + +type SendMetrics = (metrics: IDEMetric[]) => Promise; export class GitpodInsightsAppender implements ITelemetryAppender { - private _baseProperties: { appName: string; uiKind: 'web'; version: string }; + private readonly _baseProperties: { appName: string; uiKind: 'web'; version: string }; + private readonly devMode = this.productService.nameShort.endsWith(' Dev'); constructor( @IProductService private readonly productService: IProductService ) { - this._baseProperties = { appName: this.productService.nameShort, uiKind: 'web', @@ -22,15 +25,87 @@ export class GitpodInsightsAppender implements ITelemetryAppender { } public log(eventName: string, data: any): void { - const trackMessage = mapTelemetryData(SenderKind.Browser, eventName, data); - if (!trackMessage) { - return; + this.sendAnalytics(eventName, data); + this.sendMetrics(eventName, data); + } + + private sendAnalytics(eventName: string, data: any): void { + try { + const trackMessage = mapTelemetryData('window', eventName, data); + if (!trackMessage) { + return; + } + trackMessage.properties = { + ...trackMessage.properties, + ...this._baseProperties, + }; + if (this.devMode) { + if (this.productService.gitpodPreview?.log?.analytics) { + console.log('Gitpod Analytics: ', JSON.stringify(trackMessage, undefined, 2)); + } + } else { + // TODO(ak) get rid of it + // it is bad usage of window.postMessage + // we should use Segment directly here and publish to production/staging untrusted + // use server api to resolve a user + window.postMessage({ type: 'vscode_telemetry', event: trackMessage.event, properties: trackMessage.properties }, '*'); + } + } catch (e) { + console.error('failed to send IDE analytic:', e); } - trackMessage.properties = { - ...trackMessage.properties, - ...this._baseProperties, - }; - window.postMessage({ type: 'vscode_telemetry', event: trackMessage.event, properties: trackMessage.properties }, '*'); + } + + private async sendMetrics(eventName: string, data: any): Promise { + try { + const metrics = mapMetrics('window', eventName, data); + if (!metrics || !metrics.length) { + return; + } + if (this.devMode) { + if (this.productService.gitpodPreview?.log?.metrics) { + console.log('Gitpod Metrics: ', JSON.stringify(metrics, undefined, 2)); + } + } + const doSendMetrics = await this.getSendMetrics(); + if (doSendMetrics) { + await doSendMetrics(metrics); + } + } catch (e) { + console.error('failed to send IDE metric:', e); + } + } + + private _sendMetrics: Promise | undefined; + private getSendMetrics(): Promise { + if (this._sendMetrics) { + return this._sendMetrics; + } + return this._sendMetrics = (async () => { + let gitpodHost: string | undefined; + if (!this.devMode) { + const infoResponse = await fetch(window.location.protocol + '//' + window.location.host + '/_supervisor/v1/info/workspace', { + credentials: 'include' + }); + if (!infoResponse.ok) { + throw new Error(`Getting workspace info failed: ${infoResponse.statusText}`); + } + const info: { gitpodHost: string } = await infoResponse.json(); + gitpodHost = new URL(info.gitpodHost).host; + } else if (this.productService.gitpodPreview) { + gitpodHost = this.productService.gitpodPreview.host; + } + if (!gitpodHost) { + return undefined; + } + // load grpc-web before see https://github.com/gitpod-io/gitpod/issues/4448 + await import('@improbable-eng/grpc-web'); + const { MetricsServiceClient, sendMetrics } = await import('@gitpod/ide-metrics-api-grpcweb'); + const ideMetricsEndpoint = 'https://ide.' + gitpodHost + '/metrics-api'; + const client = new MetricsServiceClient(ideMetricsEndpoint); + return async (metrics: IDEMetric[]) => { + await sendMetrics(client, metrics); + }; + })(); } public flush(): Promise { diff --git a/src/vs/gitpod/common/insightsHelper.ts b/src/vs/gitpod/common/insightsHelper.ts index 28a658d2e7d40..235a4bc0fd868 100644 --- a/src/vs/gitpod/common/insightsHelper.ts +++ b/src/vs/gitpod/common/insightsHelper.ts @@ -5,6 +5,7 @@ *--------------------------------------------------------------------------------------------*/ import { RemoteTrackMessage } from '@gitpod/gitpod-protocol/lib/analytics'; +import type { IDEMetric } from '@gitpod/ide-metrics-api-grpcweb/lib/index'; function getEventName(name: string) { @@ -27,10 +28,79 @@ export enum SenderKind { Node = 2 } -// Please don't send same event for both Browser and Node +export function mapMetrics(source: 'window' | 'remote-server', eventName: string, data: any): IDEMetric[] | undefined { + const maybeMetrics = doMapMetrics(source, eventName, data); + return maybeMetrics instanceof Array ? maybeMetrics : typeof maybeMetrics === 'object' ? [maybeMetrics] : undefined; +} +function doMapMetrics(source: 'window' | 'remote-server', eventName: string, data: any): IDEMetric[] | IDEMetric | undefined { + if (source === 'remote-server') { + if (eventName.startsWith('extensionGallery:')) { + const operation = eventName.split(':')[1]; + if (operation === 'install' || operation === 'update' || operation === 'uninstall') { + const metrics: IDEMetric[] = [{ + kind: 'counter', + name: 'gitpod_vscode_extension_gallery_operation_total', + labels: { + operation, + status: data.success ? 'success' : 'failure', + // TODO errorCode + } + }]; + if (typeof data.duration === 'number') { + metrics.push({ + kind: 'histogram', + name: 'gitpod_vscode_extension_gallery_operation_duration_seconds', + labels: { + operation + }, + value: data.duration / 1000 + }); + } + return metrics; + } + } + if (eventName === 'galleryService:query') { + const metrics: IDEMetric[] = [{ + kind: 'counter', + name: 'gitpod_vscode_extension_gallery_query_total', + labels: { + status: data.success ? 'success' : 'failure', + statusCode: data.statusCode, + errorCode: data.errorCode, + } + }, { + kind: 'histogram', + name: 'gitpod_vscode_extension_gallery_query_duration_seconds', + labels: {}, + value: data.duration / 1000 + }]; + return metrics; + } + } + return undefined; +} -export function mapTelemetryData(kind: SenderKind, eventName: string, data: any): RemoteTrackMessage | undefined { - if (kind === SenderKind.Node) { +// please don't send same metrics from browser window and remote server +export function mapTelemetryData(source: 'window' | 'remote-server', eventName: string, data: any): RemoteTrackMessage | undefined { + if (source === 'remote-server') { + if (eventName.startsWith('extensionGallery:')) { + const operation = eventName.split(':')[1]; + if (operation === 'install' || operation === 'update' || operation === 'uninstall') { + return { + event: 'vscode_extension_gallery', + properties: { + kind: operation, + extensionId: data.id, + workspaceId: data.workspaceId, + workspaceInstanceId: data.workspaceInstanceId, + sessionID: data.sessionID, + timestamp: data.timestamp, + success: data.success, + errorCode: data.errorcode, + }, + }; + } + } switch (eventName) { case 'editorOpened': if (readAccessTracked || (data.typeId) !== 'workbench.editors.files.fileEditorInput') { @@ -127,42 +197,6 @@ export function mapTelemetryData(kind: SenderKind, eventName: string, data: any) timestamp: data.timestamp }, }; - case 'extensionGallery:install': - return { - event: 'vscode_extension_gallery', - properties: { - kind: 'install', - extensionId: data.id, - workspaceId: data.workspaceId, - workspaceInstanceId: data.workspaceInstanceId, - sessionID: data.sessionID, - timestamp: data.timestamp - }, - }; - case 'extensionGallery:update': - return { - event: 'vscode_extension_gallery', - properties: { - kind: 'update', - extensionId: data.id, - workspaceId: data.workspaceId, - workspaceInstanceId: data.workspaceInstanceId, - sessionID: data.sessionID, - timestamp: data.timestamp - }, - }; - case 'extensionGallery:uninstall': - return { - event: 'vscode_extension_gallery', - properties: { - kind: 'uninstall', - extensionId: data.id, - workspaceId: data.workspaceId, - workspaceInstanceId: data.workspaceInstanceId, - sessionID: data.sessionID, - timestamp: data.timestamp - }, - }; case 'gettingStarted.ActionExecuted': return { event: 'vscode_getting_started', @@ -191,7 +225,7 @@ export function mapTelemetryData(kind: SenderKind, eventName: string, data: any) }, }; } - } else if (kind === SenderKind.Browser) { + } else if (source === 'window') { switch (eventName) { case 'remoteConnectionSuccess': case 'remoteConnectionFailure': diff --git a/src/vs/gitpod/node/gitpodInsightsAppender.ts b/src/vs/gitpod/node/gitpodInsightsAppender.ts index 9c36e24db5763..3b139acef4a9d 100644 --- a/src/vs/gitpod/node/gitpodInsightsAppender.ts +++ b/src/vs/gitpod/node/gitpodInsightsAppender.ts @@ -13,14 +13,17 @@ import { StatusServiceClient } from '@gitpod/supervisor-api-grpc/lib/status_grpc import { InfoServiceClient } from '@gitpod/supervisor-api-grpc/lib/info_grpc_pb'; import { TokenServiceClient } from '@gitpod/supervisor-api-grpc/lib/token_grpc_pb'; import { ContentStatusRequest } from '@gitpod/supervisor-api-grpc/lib/status_pb'; -import { WorkspaceInfoRequest } from '@gitpod/supervisor-api-grpc/lib/info_pb'; +import { WorkspaceInfoRequest, WorkspaceInfoResponse } from '@gitpod/supervisor-api-grpc/lib/info_pb'; import * as ReconnectingWebSocket from 'reconnecting-websocket'; import * as WebSocket from 'ws'; import { ConsoleLogger, listen as doListen } from 'vscode-ws-jsonrpc'; import * as grpc from '@grpc/grpc-js'; import * as util from 'util'; import { filter, mixin } from 'vs/base/common/objects'; -import { mapTelemetryData, SenderKind } from 'vs/gitpod/common/insightsHelper'; +import { mapMetrics, mapTelemetryData } from 'vs/gitpod/common/insightsHelper'; +import { MetricsServiceClient, sendMetrics } from '@gitpod/ide-metrics-api-grpcweb'; +import { IGitpodPreviewConfiguration } from 'vs/base/common/product'; +import { NodeHttpTransport } from '@improbable-eng/grpc-web-node-http-transport'; class SupervisorConnection { readonly deadlines = { @@ -45,94 +48,15 @@ type GitpodConnection = Omit, 'ser server: Pick; }; -async function getSupervisorData() { - const supervisor = new SupervisorConnection(); - - let contentAvailable = false; - while (!contentAvailable) { - try { - const contentStatusRequest = new ContentStatusRequest(); - contentStatusRequest.setWait(true); - const result = await util.promisify(supervisor.status.contentStatus.bind(supervisor.status, contentStatusRequest, supervisor.metadata, { - deadline: Date.now() + supervisor.deadlines.long - }))(); - contentAvailable = result.getAvailable(); - } catch (e) { - console.error('Cannot maintain connection to supervisor', e); - } - } - - const workspaceInfo = await util.promisify(supervisor.info.workspaceInfo.bind(supervisor.info, new WorkspaceInfoRequest(), supervisor.metadata, { - deadline: Date.now() + supervisor.deadlines.long - }))(); - const gitpodApi = workspaceInfo.getGitpodApi()!; - const gitpodApiHost = gitpodApi.getHost(); - const gitpodApiEndpoint = gitpodApi.getEndpoint(); - const gitpodHost = workspaceInfo.getGitpodHost(); - const workspaceId = workspaceInfo.getWorkspaceId(); - const instanceId = workspaceInfo.getInstanceId(); - - const getTokenRequest = new GetTokenRequest(); - getTokenRequest.setKind('gitpod'); - getTokenRequest.setHost(gitpodApiHost); - getTokenRequest.addScope('function:trackEvent'); - - const getTokenResponse = await util.promisify(supervisor.token.getToken.bind(supervisor.token, getTokenRequest, supervisor.metadata, { - deadline: Date.now() + supervisor.deadlines.long - }))(); - const serverToken = getTokenResponse.getToken(); - - return { - serverToken, - gitpodHost, - gitpodApiEndpoint, - workspaceId, - instanceId - }; -} - -async function getClient(productName: string, productVersion: string, serverToken: string, gitpodHost: string, gitpodApiEndpoint: string): Promise { - const factory = new JsonRpcProxyFactory(); - const gitpodService = new GitpodServiceImpl(factory.createProxy()) as GitpodConnection; - - const webSocket = new (ReconnectingWebSocket as any)(gitpodApiEndpoint, undefined, { - maxReconnectionDelay: 10000, - minReconnectionDelay: 1000, - reconnectionDelayGrowFactor: 1.3, - connectionTimeout: 10000, - maxRetries: Infinity, - debug: false, - startClosed: false, - WebSocket: class extends WebSocket { - constructor(address: string, protocols?: string | string[]) { - super(address, protocols, { - headers: { - 'Origin': new URL(gitpodHost).origin, - 'Authorization': `Bearer ${serverToken}`, - 'User-Agent': productName, - 'X-Client-Version': productVersion, - } - }); - } - } - }); - webSocket.onerror = console.error; - doListen({ - webSocket: webSocket as any, - onConnection: connection => factory.listen(connection), - logger: new ConsoleLogger() - }); - - return gitpodService; -} - export class GitpodInsightsAppender implements ITelemetryAppender { private _asyncAIClient: Promise | null; private _defaultData: { [key: string]: any } = Object.create(null); private _baseProperties: { appName: string; uiKind: 'web'; version: string }; + private readonly supervisor = new SupervisorConnection(); + private readonly devMode = this.productName.endsWith(' Dev'); - constructor(private productName: string, private productVersion: string) { + constructor(private productName: string, private productVersion: string, private readonly gitpodPreview?: IGitpodPreviewConfiguration) { this._asyncAIClient = null; this._baseProperties = { appName: productName, @@ -143,12 +67,14 @@ export class GitpodInsightsAppender implements ITelemetryAppender { private _withAIClient(callback: (aiClient: Pick) => void): void { if (!this._asyncAIClient) { - this._asyncAIClient = getSupervisorData().then( + this._asyncAIClient = this.getSupervisorData().then( (supervisorData) => { this._defaultData['workspaceId'] = supervisorData.workspaceId; + this._defaultData['instanceId'] = supervisorData.instanceId; + // TODO for backward compatibility with reports, we use instanceId in other places this._defaultData['workspaceInstanceId'] = supervisorData.instanceId; - return getClient(this.productName, this.productVersion, supervisorData.serverToken, supervisorData.gitpodHost, supervisorData.gitpodApiEndpoint); + return this.getClient(this.productName, this.productVersion, supervisorData.serverToken, supervisorData.gitpodHost, supervisorData.gitpodApiEndpoint); } ); } @@ -165,22 +91,176 @@ export class GitpodInsightsAppender implements ITelemetryAppender { } log(eventName: string, data?: any): void { - this._withAIClient((aiClient) => { - data = mixin(data, this._defaultData); - data = validateTelemetryData(data); - const mappedEvent = mapTelemetryData(SenderKind.Node, eventName, data.properties); - if (mappedEvent) { - mappedEvent.properties = filter(mappedEvent.properties, (_, v) => v !== undefined && v !== null); - mappedEvent.properties = { - ...mappedEvent.properties, - ...this._baseProperties, - }; - aiClient.trackEvent(mappedEvent); + this.sendAnalytics(data, eventName); + this.sendMetrics(data, eventName); + } + + private async sendAnalytics(data: any, eventName: string): Promise { + try { + if (this.devMode) { + if (this.gitpodPreview?.log?.analytics) { + const mappedEvent = mapTelemetryData('remote-server', eventName, data); + if (mappedEvent) { + console.log('Gitpod Analytics: ', JSON.stringify(mappedEvent, undefined, 2)); + } + } + } else { + this._withAIClient((aiClient) => { + data = mixin(data, this._defaultData); + data = validateTelemetryData(data); + const mappedEvent = mapTelemetryData('remote-server', eventName, data.properties); + if (mappedEvent) { + mappedEvent.properties = filter(mappedEvent.properties, (_, v) => v !== undefined && v !== null); + mappedEvent.properties = { + ...mappedEvent.properties, + ...this._baseProperties, + }; + aiClient.trackEvent(mappedEvent); + } + }); } - }); + } catch (e) { + console.error('failed to send IDE analytics:', e); + } + } + + private async sendMetrics(data: any, eventName: string): Promise { + try { + const metrics = mapMetrics('remote-server', eventName, data); + if (!metrics || !metrics.length) { + return; + } + if (this.devMode && this.gitpodPreview?.log?.metrics) { + console.log('Gitpod Metrics: ', JSON.stringify(metrics, undefined, 2)); + } + const client = await this.getMetricsClient(); + if (client) { + await sendMetrics(client, metrics); + } + } catch (e) { + console.error('failed to send IDE metric:', e); + } } flush(): Promise { return Promise.resolve(undefined); } + + private _metricsClient: Promise | undefined; + private getMetricsClient(): Promise { + if (this._metricsClient) { + return this._metricsClient; + } + return this._metricsClient = (async () => { + let gitpodHost: string | undefined; + if (!this.devMode) { + const info = await this.getWorkspaceInfo(); + gitpodHost = new URL(info.getGitpodHost()).host; + } else if (this.gitpodPreview) { + gitpodHost = this.gitpodPreview.host; + } + if (!gitpodHost) { + return undefined; + } + const ideMetricsEndpoint = 'https://ide.' + gitpodHost + '/metrics-api'; + return new MetricsServiceClient(ideMetricsEndpoint, { + transport: NodeHttpTransport(), + }); + })(); + } + + private _workspaceInfo: Promise | undefined; + private getWorkspaceInfo(): Promise { + if (this._workspaceInfo) { + return this._workspaceInfo; + } + return this._workspaceInfo = (async () => { + const supervisor = this.supervisor; + + let contentAvailable = false; + while (!contentAvailable) { + try { + const contentStatusRequest = new ContentStatusRequest(); + contentStatusRequest.setWait(true); + const result = await util.promisify(supervisor.status.contentStatus.bind(supervisor.status, contentStatusRequest, supervisor.metadata, { + deadline: Date.now() + supervisor.deadlines.long + }))(); + contentAvailable = result.getAvailable(); + } catch (e) { + console.error('Cannot maintain connection to supervisor', e); + } + } + + return util.promisify(supervisor.info.workspaceInfo.bind(supervisor.info, new WorkspaceInfoRequest(), supervisor.metadata, { + deadline: Date.now() + supervisor.deadlines.long + }))(); + })(); + } + + private async getSupervisorData() { + const workspaceInfo = await this.getWorkspaceInfo(); + + const gitpodApi = workspaceInfo.getGitpodApi()!; + const gitpodApiHost = gitpodApi.getHost(); + const gitpodApiEndpoint = gitpodApi.getEndpoint(); + const gitpodHost = workspaceInfo.getGitpodHost(); + const workspaceId = workspaceInfo.getWorkspaceId(); + const instanceId = workspaceInfo.getInstanceId(); + + const getTokenRequest = new GetTokenRequest(); + getTokenRequest.setKind('gitpod'); + getTokenRequest.setHost(gitpodApiHost); + getTokenRequest.addScope('function:trackEvent'); + + + const supervisor = this.supervisor; + const getTokenResponse = await util.promisify(supervisor.token.getToken.bind(supervisor.token, getTokenRequest, supervisor.metadata, { + deadline: Date.now() + supervisor.deadlines.long + }))(); + const serverToken = getTokenResponse.getToken(); + + return { + serverToken, + gitpodHost, + gitpodApiEndpoint, + workspaceId, + instanceId, + }; + } + + // TODO(ak) publish to Segment directly to production/staging untrusted instead, use server api only to resolve a user + private async getClient(productName: string, productVersion: string, serverToken: string, gitpodHost: string, gitpodApiEndpoint: string): Promise { + const factory = new JsonRpcProxyFactory(); + const gitpodService = new GitpodServiceImpl(factory.createProxy()) as GitpodConnection; + + const webSocket = new (ReconnectingWebSocket as any)(gitpodApiEndpoint, undefined, { + maxReconnectionDelay: 10000, + minReconnectionDelay: 1000, + reconnectionDelayGrowFactor: 1.3, + connectionTimeout: 10000, + maxRetries: Infinity, + debug: false, + startClosed: false, + WebSocket: class extends WebSocket { + constructor(address: string, protocols?: string | string[]) { + super(address, protocols, { + headers: { + 'Origin': new URL(gitpodHost).origin, + 'Authorization': `Bearer ${serverToken}`, + 'User-Agent': productName, + 'X-Client-Version': productVersion, + } + }); + } + } + }); + webSocket.onerror = console.error; + doListen({ + webSocket: webSocket as any, + onConnection: connection => factory.listen(connection), + logger: new ConsoleLogger() + }); + + return gitpodService; + } } diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index 906e7f1247b6e..70f7d76c0b473 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -140,7 +140,7 @@ export async function setupServerServices(connectionToken: ServerConnectionToken disposables.add(toDisposable(() => oneDsAppender?.flush())); // Ensure the AI appender is disposed so that it flushes remaining data } - oneDsAppender = new GitpodInsightsAppender(productService.nameShort, productService.version); + oneDsAppender = new GitpodInsightsAppender(productService.nameShort, productService.version, productService.gitpodPreview); const config: ITelemetryServiceConfig = { appenders: [oneDsAppender], diff --git a/yarn.lock b/yarn.lock index 60da92fe92303..3eb8829afb9c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -382,6 +382,14 @@ vscode-ws-jsonrpc "^0.2.0" ws "^7.4.6" +"@gitpod/ide-metrics-api-grpcweb@ak-ext-metrics": + version "0.0.1-ak-ext-metrics.4" + resolved "https://registry.yarnpkg.com/@gitpod/ide-metrics-api-grpcweb/-/ide-metrics-api-grpcweb-0.0.1-ak-ext-metrics.4.tgz#9dacee7f13181e132fba9e4a5b97cb7f4b1739d2" + integrity sha512-s1C4W5Q7nlgyaQGCKqG8gI1ZdzwsaFZW2k8VX5hJKIcpD5S60I7AJbP1wVxYhRqR93YW3++DHydSpbjBUVnQFA== + dependencies: + "@improbable-eng/grpc-web" "^0.14.0" + google-protobuf "^3.19.1" + "@gitpod/local-app-api-grpcweb@main": version "0.1.5-main.943" resolved "https://registry.yarnpkg.com/@gitpod/local-app-api-grpcweb/-/local-app-api-grpcweb-0.1.5-main.943.tgz#37d08d3d4336f86f816f2c1fc5503a5a4142e1e2" @@ -456,6 +464,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== +"@improbable-eng/grpc-web-node-http-transport@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@improbable-eng/grpc-web-node-http-transport/-/grpc-web-node-http-transport-0.15.0.tgz#5a064472ef43489cbd075a91fb831c2abeb09d68" + integrity sha512-HLgJfVolGGpjc9DWPhmMmXJx8YGzkek7jcCFO1YYkSOoO81MWRZentPOd/JiKiZuU08wtc4BG+WNuGzsQB5jZA== + "@improbable-eng/grpc-web@0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@improbable-eng/grpc-web/-/grpc-web-0.14.0.tgz#a71c5af471dcef6a2810798f71f93ed8d6ac3817" @@ -463,6 +476,13 @@ dependencies: browser-headers "^0.4.1" +"@improbable-eng/grpc-web@^0.14.0": + version "0.14.1" + resolved "https://registry.yarnpkg.com/@improbable-eng/grpc-web/-/grpc-web-0.14.1.tgz#f4662f64dc89c0f956a94bb8a3b576556c74589c" + integrity sha512-XaIYuunepPxoiGVLLHmlnVminUGzBTnXr8Wv7khzmLWbNw4TCwJKX09GSMJlKhu/TRk6gms0ySFxewaETSBqgw== + dependencies: + browser-headers "^0.4.1" + "@istanbuljs/schema@^0.1.2": version "0.1.2" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.2.tgz#26520bf09abe4a5644cd5414e37125a8954241dd"