Skip to content

Commit 5c0d9be

Browse files
authored
Add SLOs reports for local ssh IDE-154 (#78)
1 parent b00b460 commit 5c0d9be

File tree

9 files changed

+157
-75
lines changed

9 files changed

+157
-75
lines changed

src/common/metrics.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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 { ILogService } from '../services/logService';
7+
import { isBuiltFromGHA } from './utils';
8+
import fetch from 'node-fetch-commonjs';
9+
10+
const metricsHostMap = new Map<string, string>();
11+
12+
export async function addCounter(gitpodHost: string | undefined, name: string, labels: Record<string, string>, value: number, logService: ILogService) {
13+
const data = {
14+
name,
15+
labels,
16+
value,
17+
};
18+
if (!gitpodHost) {
19+
logService.error('Missing \'gitpodHost\' in metrics add counter');
20+
return;
21+
}
22+
if (!isBuiltFromGHA) {
23+
logService.trace('Local metrics add counter', data);
24+
return;
25+
}
26+
const metricsHost = getMetricsHost(gitpodHost);
27+
const resp = await fetch(
28+
`https://${metricsHost}/metrics-api/metrics/counter/add/${name}`,
29+
{
30+
method: 'POST',
31+
headers: {
32+
'Content-Type': 'application/json',
33+
'X-Client': 'vscode-desktop-extension'
34+
},
35+
body: JSON.stringify(data)
36+
}
37+
);
38+
39+
if (!resp.ok) {
40+
throw new Error(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`);
41+
}
42+
}
43+
44+
export async function addHistogram(gitpodHost: string | undefined, name: string, labels: Record<string, string>, count: number, sum: number, buckets: number[], logService: ILogService) {
45+
const data = {
46+
name,
47+
labels,
48+
count,
49+
sum,
50+
buckets,
51+
};
52+
if (!gitpodHost) {
53+
logService.error('Missing \'gitpodHost\' in metrics add histogram');
54+
return;
55+
}
56+
if (!isBuiltFromGHA) {
57+
logService.trace('Local metrics add histogram', data);
58+
return;
59+
}
60+
const metricsHost = getMetricsHost(gitpodHost);
61+
const resp = await fetch(
62+
`https://${metricsHost}/metrics-api/metrics/histogram/add/${name}`,
63+
{
64+
method: 'POST',
65+
headers: {
66+
'Content-Type': 'application/json',
67+
'X-Client': 'vscode-desktop-extension'
68+
},
69+
body: JSON.stringify(data)
70+
}
71+
);
72+
73+
if (!resp.ok) {
74+
throw new Error(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`);
75+
}
76+
}
77+
78+
function getMetricsHost(gitpodHost: string): string {
79+
if (metricsHostMap.has(gitpodHost)) {
80+
return metricsHostMap.get(gitpodHost)!;
81+
}
82+
const serviceUrl = new URL(gitpodHost);
83+
const metricsHost = `ide.${serviceUrl.hostname}`;
84+
metricsHostMap.set(gitpodHost, metricsHost);
85+
return metricsHost;
86+
}

src/common/telemetry.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
import * as os from 'os';
77
import { Analytics, AnalyticsSettings } from '@segment/analytics-node';
88
import { ILogService } from '../services/logService';
9-
import { cloneAndChange, escapeRegExpCharacters, mixin } from '../common/utils';
10-
11-
export const ProductionUntrustedSegmentKey = 'untrusted-dummy-key';
12-
9+
import { cloneAndChange, escapeRegExpCharacters, isBuiltFromGHA, mixin } from '../common/utils';
1310

1411
export const TRUSTED_VALUES = new Set([
1512
'gitpodHost'
@@ -51,7 +48,7 @@ export function createSegmentAnalyticsClient(settings: AnalyticsSettings, gitpod
5148
host: 'https://api.segment.io',
5249
path: '/v1/batch'
5350
};
54-
if (updatedSettings.writeKey === ProductionUntrustedSegmentKey) {
51+
if (isBuiltFromGHA) {
5552
updatedSettings.host = gitpodHost;
5653
updatedSettings.path = '/analytics/v1/batch';
5754
} else {
@@ -76,6 +73,11 @@ export function commonSendEventData(logService: ILogService, segmentClient: Anal
7673

7774
delete properties['gitpodHost'];
7875

76+
if (!isBuiltFromGHA) {
77+
logService.trace('Local event report', eventName, properties);
78+
return;
79+
}
80+
7981
segmentClient.track({
8082
anonymousId: machineId,
8183
event: eventName,
@@ -108,7 +110,7 @@ export function getCleanupPatterns(piiPaths: string[]) {
108110
return cleanupPatterns;
109111
}
110112

111-
export function commonSendErrorData(logService: ILogService, segmentKey: string, defaultGitpodHost: string, error: Error, data: any | undefined, options: SendErrorDataOptions) {
113+
export function commonSendErrorData(logService: ILogService, defaultGitpodHost: string, error: Error, data: any | undefined, options: SendErrorDataOptions) {
112114
const { cleanupPatterns, commonProperties, isTrustedValue } = options;
113115
let properties = cleanData(data ?? {}, cleanupPatterns, isTrustedValue);
114116
properties = mixin(properties, commonProperties);
@@ -141,7 +143,7 @@ export function commonSendErrorData(logService: ILogService, segmentKey: string,
141143
properties,
142144
};
143145

144-
if (segmentKey !== ProductionUntrustedSegmentKey) {
146+
if (!isBuiltFromGHA) {
145147
logService.trace('Local error report', jsonData);
146148
return;
147149
}

src/common/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,6 @@ export class WrapError extends Error {
126126
this.code = code ? code : err.code;
127127
}
128128
}
129+
130+
const ProductionUntrustedSegmentKey = 'untrusted-dummy-key';
131+
export const isBuiltFromGHA = process.env.SEGMENT_KEY === ProductionUntrustedSegmentKey;

src/local-ssh/proxy.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import * as stream from 'stream';
1414
import { ILogService } from '../services/logService';
1515
import { TelemetryService } from './telemetryService';
1616
import { ITelemetryService, UserFlowTelemetryProperties } from '../common/telemetry';
17+
import { LocalSSHMetricsReporter } from '../services/localSSHMetrics';
1718

1819
// This public key is safe to be public since we only use it to verify local-ssh connections.
1920
const HOST_KEY = 'LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JR0hBZ0VBTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEJHMHdhd0lCQVFRZ1QwcXg1eEJUVmc4TUVJbUUKZmN4RXRZN1dmQVVsM0JYQURBK2JYREsyaDZlaFJBTkNBQVJlQXo0RDVVZXpqZ0l1SXVOWXpVL3BCWDdlOXoxeApvZUN6UklqcGdCUHozS0dWRzZLYXV5TU5YUm95a21YSS9BNFpWaW9nd2Vjb0FUUjRUQ2FtWm1ScAotLS0tLUVORCBQUklWQVRFIEtFWS0tLS0tCg==';
@@ -77,6 +78,7 @@ class WebSocketSSHProxy {
7778
constructor(
7879
private readonly options: ClientOptions,
7980
private readonly telemetryService: ITelemetryService,
81+
private readonly metricsReporter: LocalSSHMetricsReporter,
8082
private readonly logService: ILogService
8183
) {
8284
this.flow = {
@@ -249,6 +251,9 @@ class WebSocketSSHProxy {
249251
}
250252

251253
async sendUserStatusFlow(status: 'connected' | 'connecting' | 'failed') {
254+
this.metricsReporter.reportConnectionStatus(this.flow.gitpodHost, status, this.flow.failureCode).catch(e => {
255+
this.logService.error('Failed to report connection status', e);
256+
});
252257
this.telemetryService.sendUserFlowStatus(status, this.flow);
253258
}
254259

@@ -292,7 +297,9 @@ const telemetryService = new TelemetryService(
292297
logService
293298
);
294299

295-
const proxy = new WebSocketSSHProxy(options, telemetryService, logService);
300+
const metricsReporter = new LocalSSHMetricsReporter(logService);
301+
302+
const proxy = new WebSocketSSHProxy(options, telemetryService, metricsReporter, logService);
296303
proxy.start().catch(() => {
297304
// Noop, catch everything in start method pls
298305
});

src/local-ssh/telemetryService.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class TelemetryService implements ITelemetryService {
4444
}
4545

4646
sendErrorData(error: Error, data?: Record<string, any>) {
47-
commonSendErrorData(this.logService, this.segmentKey, this.gitpodHost, error, data, {
47+
commonSendErrorData(this.logService, this.gitpodHost, error, data, {
4848
cleanupPatterns: this.cleanupPatterns,
4949
commonProperties: this.commonProperties,
5050
isTrustedValue

src/metrics.ts

Lines changed: 5 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Registry, Counter, Histogram, metric } from 'prom-client';
88
import { MethodKind } from '@bufbuild/protobuf';
99
import { StreamResponse, UnaryResponse, Code, connectErrorFromReason, Interceptor, StreamRequest, UnaryRequest } from '@bufbuild/connect';
1010
import { ILogService } from './services/logService';
11+
import { addCounter, addHistogram } from './common/metrics';
1112

1213
export type GrpcMethodType = 'unary' | 'client_stream' | 'server_stream' | 'bidi_stream';
1314

@@ -255,16 +256,12 @@ export function getGrpcMetricsInterceptor(): grpc.Interceptor {
255256
export class MetricsReporter {
256257
private static readonly REPORT_INTERVAL = 60000;
257258

258-
private metricsHost: string;
259259
private intervalHandler: NodeJS.Timer | undefined;
260260

261261
constructor(
262-
gitpodHost: string,
262+
private readonly gitpodHost: string,
263263
private readonly logger: ILogService
264-
) {
265-
const serviceUrl = new URL(gitpodHost);
266-
this.metricsHost = `ide.${serviceUrl.hostname}`;
267-
}
264+
) { }
268265

269266
startReporting() {
270267
if (this.intervalHandler) {
@@ -295,7 +292,7 @@ export class MetricsReporter {
295292
const counterMetric = metric as metric & { values: [{ value: number; labels: Record<string, string> }] };
296293
for (const { value, labels } of counterMetric.values) {
297294
if (value > 0) {
298-
await this.doAddCounter(counterMetric.name, labels, value);
295+
await addCounter(this.gitpodHost, counterMetric.name, labels, value, this.logger);
299296
}
300297
}
301298
}
@@ -316,64 +313,14 @@ export class MetricsReporter {
316313
sum = value;
317314
} else if (metricName.endsWith('_count')) {
318315
if (value > 0) {
319-
await this.doAddHistogram(histogramMetric.name, labels, value, sum, buckets);
316+
await addHistogram(this.gitpodHost, histogramMetric.name, labels, value, sum, buckets, this.logger);
320317
}
321318
sum = 0;
322319
buckets = [];
323320
}
324321
}
325322
}
326323

327-
private async doAddCounter(name: string, labels: Record<string, string>, value: number) {
328-
const data = {
329-
name,
330-
labels,
331-
value,
332-
};
333-
334-
const resp = await fetch(
335-
`https://${this.metricsHost}/metrics-api/metrics/counter/add/${name}`,
336-
{
337-
method: 'POST',
338-
headers: {
339-
'Content-Type': 'application/json',
340-
'X-Client': 'vscode-desktop-extension'
341-
},
342-
body: JSON.stringify(data)
343-
}
344-
);
345-
346-
if (!resp.ok) {
347-
throw new Error(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`);
348-
}
349-
}
350-
351-
private async doAddHistogram(name: string, labels: Record<string, string>, count: number, sum: number, buckets: number[]) {
352-
const data = {
353-
name,
354-
labels,
355-
count,
356-
sum,
357-
buckets,
358-
};
359-
360-
const resp = await fetch(
361-
`https://${this.metricsHost}/metrics-api/metrics/histogram/add/${name}`,
362-
{
363-
method: 'POST',
364-
headers: {
365-
'Content-Type': 'application/json',
366-
'X-Client': 'vscode-desktop-extension'
367-
},
368-
body: JSON.stringify(data)
369-
}
370-
);
371-
372-
if (!resp.ok) {
373-
throw new Error(`Metrics endpoint responded with ${resp.status} ${resp.statusText}`);
374-
}
375-
}
376-
377324
stopReporting() {
378325
if (this.intervalHandler) {
379326
clearInterval(this.intervalHandler);

src/services/localSSHMetrics.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
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 { addCounter } from '../common/metrics';
7+
import { ILogService } from './logService';
8+
9+
export class LocalSSHMetricsReporter {
10+
11+
constructor(
12+
private readonly logService: ILogService,
13+
) { }
14+
15+
async reportConfigStatus(gitpodHost: string | undefined, status: 'success' | 'failure', failureCode?: string): Promise<void> {
16+
if (status === 'success') {
17+
failureCode = 'None';
18+
}
19+
return addCounter(gitpodHost, 'vscode_desktop_local_ssh_config_total', { status, failure_code: failureCode ?? 'Unknown' }, 1, this.logService);
20+
}
21+
22+
async reportPingExtensionStatus(gitpodHost: string | undefined, status: 'success' | 'failure'): Promise<void> {
23+
return addCounter(gitpodHost, 'vscode_desktop_ping_extension_server_total', { status }, 1, this.logService);
24+
}
25+
26+
async reportConnectionStatus(gitpodHost: string | undefined, phase: 'connected' | 'connecting' | 'failed', failureCode?: string): Promise<void> {
27+
if (phase === 'connecting' || phase === 'connected') {
28+
failureCode = 'None';
29+
}
30+
return addCounter(gitpodHost, 'vscode_desktop_local_ssh_total', { phase, failure_code: failureCode ?? 'Unknown' }, 1, this.logService);
31+
}
32+
}

src/services/localSSHService.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { ITelemetryService, UserFlowTelemetryProperties } from '../common/teleme
1717
import { ISessionService } from './sessionService';
1818
import { WrapError } from '../common/utils';
1919
import { canExtensionServiceServerWork } from '../local-ssh/ipc/extensionServiceServer';
20+
import { LocalSSHMetricsReporter } from './localSSHMetrics';
2021

2122
export interface ILocalSSHService {
2223
flow?: UserFlowTelemetryProperties;
@@ -35,6 +36,8 @@ export class LocalSSHService extends Disposable implements ILocalSSHService {
3536

3637
public flow?: UserFlowTelemetryProperties;
3738

39+
private metricsReporter: LocalSSHMetricsReporter;
40+
3841
constructor(
3942
private readonly context: vscode.ExtensionContext,
4043
private readonly hostService: IHostService,
@@ -43,6 +46,7 @@ export class LocalSSHService extends Disposable implements ILocalSSHService {
4346
private readonly logService: ILogService,
4447
) {
4548
super();
49+
this.metricsReporter = new LocalSSHMetricsReporter(logService);
4650
}
4751

4852
async initialize(): Promise<boolean> {
@@ -75,6 +79,7 @@ export class LocalSSHService extends Disposable implements ILocalSSHService {
7579
async extensionServerReady(): Promise<boolean> {
7680
try {
7781
await canExtensionServiceServerWork();
82+
this.metricsReporter.reportPingExtensionStatus(this.flow?.gitpodHost, 'success');
7883
return true;
7984
} catch (e) {
8085
const failureCode = 'ExtensionServerUnavailable';
@@ -93,6 +98,7 @@ export class LocalSSHService extends Disposable implements ILocalSSHService {
9398
workspaceId: flow.workspaceId,
9499
});
95100
this.telemetryService.sendUserFlowStatus('failure', flow);
101+
this.metricsReporter.reportPingExtensionStatus(flow.gitpodHost, 'failure');
96102
return false;
97103
}
98104
}
@@ -107,6 +113,7 @@ export class LocalSSHService extends Disposable implements ILocalSSHService {
107113
await this.configureSettings(locations);
108114
});
109115

116+
this.metricsReporter.reportConfigStatus(flowData.gitpodHost, 'success');
110117
this.telemetryService.sendUserFlowStatus('success', flowData);
111118
return true;
112119
} catch (e) {
@@ -122,9 +129,10 @@ export class LocalSSHService extends Disposable implements ILocalSSHService {
122129
e.message = `Failed to initialize: ${e.message}`;
123130
}
124131
if (sendErrorReport) {
125-
this.telemetryService.sendTelemetryException(e, { gitpodHost: this.hostService.gitpodHost, useLocalAPP: String(Configuration.getUseLocalApp()) });
132+
this.telemetryService.sendTelemetryException(e, { gitpodHost: flowData.gitpodHost, useLocalAPP: String(Configuration.getUseLocalApp()) });
126133
}
127134

135+
this.metricsReporter.reportConfigStatus(flowData.gitpodHost, 'failure', failureCode);
128136
this.telemetryService.sendUserFlowStatus('failure', { ...flowData, failureCode });
129137
return false;
130138
}

0 commit comments

Comments
 (0)