Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/gitpod-protocol/src/metrics.ts
2498 views
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
/**
7
* prom-client is node library, we onyl import some types and values
8
* not default node metrics
9
*/
10
import type {
11
Registry as PromRegistry,
12
Counter as PromCounter,
13
Histogram as PromHistorgram,
14
MetricObjectWithValues,
15
MetricValue,
16
MetricValueWithName,
17
} from "prom-client";
18
19
const Registry: typeof PromRegistry = require("prom-client/lib/registry");
20
const Counter: typeof PromCounter = require("prom-client/lib/counter");
21
const Histogram: typeof PromHistorgram = require("prom-client/lib/histogram");
22
23
import { MethodKind } from "@bufbuild/protobuf";
24
import {
25
StreamResponse,
26
UnaryResponse,
27
Code,
28
ConnectError,
29
Interceptor,
30
StreamRequest,
31
UnaryRequest,
32
} from "@connectrpc/connect";
33
34
type GrpcMethodType = "unary" | "client_stream" | "server_stream" | "bidi_stream";
35
36
interface IGrpcCallMetricsLabels {
37
service: string;
38
method: string;
39
type: GrpcMethodType;
40
}
41
42
interface IGrpcCallMetricsLabelsWithCode extends IGrpcCallMetricsLabels {
43
code: string;
44
}
45
46
const register = new Registry();
47
48
class PrometheusClientCallMetrics {
49
readonly startedCounter: PromCounter<string>;
50
readonly sentCounter: PromCounter<string>;
51
readonly receivedCounter: PromCounter<string>;
52
readonly handledCounter: PromCounter<string>;
53
readonly handledSecondsHistogram: PromHistorgram<string>;
54
55
readonly webSocketCounter: PromCounter<string>;
56
57
constructor() {
58
this.startedCounter = new Counter({
59
name: "grpc_client_started_total",
60
help: "Total number of RPCs started on the client.",
61
labelNames: ["grpc_service", "grpc_method", "grpc_type"],
62
registers: [register],
63
});
64
this.sentCounter = new Counter({
65
name: "grpc_client_msg_sent_total",
66
help: " Total number of gRPC stream messages sent by the client.",
67
labelNames: ["grpc_service", "grpc_method", "grpc_type"],
68
registers: [register],
69
});
70
this.receivedCounter = new Counter({
71
name: "grpc_client_msg_received_total",
72
help: "Total number of RPC stream messages received by the client.",
73
labelNames: ["grpc_service", "grpc_method", "grpc_type"],
74
registers: [register],
75
});
76
this.handledCounter = new Counter({
77
name: "grpc_client_handled_total",
78
help: "Total number of RPCs completed by the client, regardless of success or failure.",
79
labelNames: ["grpc_service", "grpc_method", "grpc_type", "grpc_code"],
80
registers: [register],
81
});
82
this.handledSecondsHistogram = new Histogram({
83
name: "grpc_client_handling_seconds",
84
help: "Histogram of response latency (seconds) of the gRPC until it is finished by the application.",
85
labelNames: ["grpc_service", "grpc_method", "grpc_type", "grpc_code"],
86
buckets: [0.1, 0.2, 0.5, 1, 2, 5, 10], // it should be aligned with https://github.com/gitpod-io/gitpod/blob/84ed1a0672d91446ba33cb7b504cfada769271a8/install/installer/pkg/components/ide-metrics/configmap.go#L315
87
registers: [register],
88
});
89
90
this.webSocketCounter = new Counter({
91
name: "websocket_client_total",
92
help: "Total number of WebSocket connections by the client",
93
labelNames: ["origin", "instance_phase", "status", "code", "was_clean"],
94
registers: [register],
95
});
96
}
97
98
started(labels: IGrpcCallMetricsLabels): void {
99
this.startedCounter.inc({
100
grpc_service: labels.service,
101
grpc_method: labels.method,
102
grpc_type: labels.type,
103
});
104
}
105
106
sent(labels: IGrpcCallMetricsLabels): void {
107
this.sentCounter.inc({
108
grpc_service: labels.service,
109
grpc_method: labels.method,
110
grpc_type: labels.type,
111
});
112
}
113
114
received(labels: IGrpcCallMetricsLabels): void {
115
this.receivedCounter.inc({
116
grpc_service: labels.service,
117
grpc_method: labels.method,
118
grpc_type: labels.type,
119
});
120
}
121
122
handled(labels: IGrpcCallMetricsLabelsWithCode): void {
123
this.handledCounter.inc({
124
grpc_service: labels.service,
125
grpc_method: labels.method,
126
grpc_type: labels.type,
127
grpc_code: labels.code,
128
});
129
}
130
131
startHandleTimer(
132
labels: IGrpcCallMetricsLabels,
133
): (endLabels?: Partial<Record<string, string | number>> | undefined) => number {
134
const startLabels = {
135
grpc_service: labels.service,
136
grpc_method: labels.method,
137
grpc_type: labels.type,
138
};
139
if (typeof window !== "undefined") {
140
const start = performance.now();
141
return (endLabels) => {
142
const delta = performance.now() - start;
143
const value = delta / 1000;
144
this.handledSecondsHistogram.labels(Object.assign(startLabels, endLabels)).observe(value);
145
return value;
146
};
147
}
148
return this.handledSecondsHistogram.startTimer(startLabels);
149
}
150
}
151
152
const metrics = new PrometheusClientCallMetrics();
153
154
export function getMetricsInterceptor(): Interceptor {
155
const getLabels = (req: UnaryRequest | StreamRequest): IGrpcCallMetricsLabels => {
156
let type: GrpcMethodType;
157
switch (req.method.kind) {
158
case MethodKind.Unary:
159
type = "unary";
160
break;
161
case MethodKind.ServerStreaming:
162
type = "server_stream";
163
break;
164
case MethodKind.ClientStreaming:
165
type = "client_stream";
166
break;
167
case MethodKind.BiDiStreaming:
168
type = "bidi_stream";
169
break;
170
}
171
return {
172
type,
173
service: req.service.typeName,
174
method: req.method.name,
175
};
176
};
177
178
return (next) => async (req) => {
179
async function* incrementStreamMessagesCounter<T>(
180
iterable: AsyncIterable<T>,
181
callback: () => void,
182
handleMetrics: boolean,
183
): AsyncIterable<T> {
184
let status: Code | undefined;
185
try {
186
for await (const item of iterable) {
187
callback();
188
yield item;
189
}
190
} catch (e) {
191
const err = ConnectError.from(e);
192
status = err.code;
193
throw e;
194
} finally {
195
if (handleMetrics && !settled) {
196
stopTimer({ grpc_code: status ? Code[status] : "OK" });
197
metrics.handled({ ...labels, code: status ? Code[status] : "OK" });
198
}
199
}
200
}
201
202
const labels = getLabels(req);
203
metrics.started(labels);
204
const stopTimer = metrics.startHandleTimer(labels);
205
206
let settled = false;
207
let status: Code | undefined;
208
try {
209
let request: UnaryRequest | StreamRequest;
210
if (!req.stream) {
211
request = req;
212
} else {
213
request = {
214
...req,
215
message: incrementStreamMessagesCounter(req.message, metrics.sent.bind(metrics, labels), false),
216
};
217
}
218
219
const res = await next(request);
220
221
let response: UnaryResponse | StreamResponse;
222
if (!res.stream) {
223
response = res;
224
settled = true;
225
} else {
226
response = {
227
...res,
228
message: incrementStreamMessagesCounter(res.message, metrics.received.bind(metrics, labels), true),
229
};
230
}
231
232
return response;
233
} catch (e) {
234
settled = true;
235
const err = ConnectError.from(e);
236
status = err.code;
237
throw e;
238
} finally {
239
if (settled) {
240
stopTimer({ grpc_code: status ? Code[status] : "OK" });
241
metrics.handled({ ...labels, code: status ? Code[status] : "OK" });
242
}
243
}
244
};
245
}
246
247
export type MetricsRequest = RequestInit & { url: string };
248
249
export class MetricsReporter {
250
private static readonly REPORT_INTERVAL = 10000;
251
252
private intervalHandler: NodeJS.Timeout | undefined;
253
254
private readonly metricsHost: string;
255
256
private sendQueue = Promise.resolve();
257
258
private readonly pendingRequests: MetricsRequest[] = [];
259
260
constructor(
261
private readonly options: {
262
gitpodUrl: string;
263
clientName: string;
264
clientVersion: string;
265
log: {
266
error: typeof console.error;
267
debug: typeof console.debug;
268
};
269
isEnabled?: () => Promise<boolean>;
270
commonErrorDetails: { [key: string]: string | undefined };
271
},
272
) {
273
this.metricsHost = `ide.${new URL(options.gitpodUrl).hostname}`;
274
if (typeof window !== "undefined") {
275
this.options.commonErrorDetails["userAgent"] = window.navigator.userAgent;
276
}
277
}
278
279
updateCommonErrorDetails(update: { [key: string]: string | undefined }) {
280
Object.assign(this.options.commonErrorDetails, update);
281
}
282
283
startReporting() {
284
if (this.intervalHandler) {
285
return;
286
}
287
this.intervalHandler = setInterval(
288
() => this.report().catch((e) => this.options.log.error("metrics: error while reporting", e)),
289
MetricsReporter.REPORT_INTERVAL,
290
);
291
}
292
293
stopReporting() {
294
if (this.intervalHandler) {
295
clearInterval(this.intervalHandler);
296
}
297
}
298
299
private async isEnabled(): Promise<boolean> {
300
if (!this.options.isEnabled) {
301
return true;
302
}
303
return this.options.isEnabled();
304
}
305
306
private async report() {
307
const enabled = await this.isEnabled();
308
if (!enabled) {
309
return;
310
}
311
if (typeof window !== undefined && !window.navigator.onLine) {
312
return;
313
}
314
315
const metrics = await register.getMetricsAsJSON();
316
register.resetMetrics();
317
for (const m of metrics) {
318
if (m.name === "grpc_client_msg_sent_total" || m.name === "grpc_client_msg_received_total") {
319
// Skip these as thy are filtered by ide metrics
320
continue;
321
}
322
323
const type = m.type as unknown as string;
324
if (type === "counter") {
325
this.syncReportCounter(m);
326
} else if (type === "histogram") {
327
this.syncReportHistogram(m);
328
}
329
}
330
331
while (this.pendingRequests.length) {
332
const request = this.pendingRequests.shift();
333
if (request) {
334
// eslint-disable-next-line @typescript-eslint/no-floating-promises
335
this.send(request);
336
}
337
}
338
}
339
340
private syncReportCounter(metric: MetricObjectWithValues<MetricValue<string>>) {
341
for (const { value, labels } of metric.values) {
342
if (value > 0) {
343
this.push(
344
this.create("metrics/counter/add/" + metric.name, {
345
name: metric.name,
346
labels,
347
value,
348
}),
349
);
350
}
351
}
352
}
353
354
private syncReportHistogram(metric: MetricObjectWithValues<MetricValueWithName<string>>) {
355
let sum = 0;
356
let buckets: number[] = [];
357
for (const { value, labels, metricName } of metric.values) {
358
if (!metricName) {
359
continue;
360
}
361
// metricName are in the following order _bucket, _sum, _count
362
// We report on _count as it's the last
363
// https://github.com/siimon/prom-client/blob/eee34858d2ef4198ff94f56a278d7b81f65e9c63/lib/histogram.js#L222-L235
364
if (metricName.endsWith("_bucket")) {
365
if (labels["le"] !== "+Inf") {
366
buckets.push(value);
367
}
368
} else if (metricName.endsWith("_sum")) {
369
sum = value;
370
} else if (metricName.endsWith("_count")) {
371
if (value > 0) {
372
this.push(
373
this.create("metrics/histogram/add/" + metric.name, {
374
name: metric.name,
375
labels,
376
count: value,
377
sum,
378
buckets,
379
}),
380
);
381
}
382
sum = 0;
383
buckets = [];
384
}
385
}
386
}
387
388
reportError(
389
error: Error,
390
data?: {
391
userId?: string;
392
workspaceId?: string;
393
instanceId?: string;
394
[key: string]: string | undefined;
395
},
396
): void {
397
// eslint-disable-next-line @typescript-eslint/no-floating-promises
398
this.asyncReportError(error, data);
399
}
400
401
private async asyncReportError(
402
error: Error,
403
data?: {
404
userId?: string;
405
workspaceId?: string;
406
instanceId?: string;
407
[key: string]: string | undefined;
408
},
409
): Promise<void> {
410
const enabled = await this.isEnabled();
411
if (!enabled) {
412
return;
413
}
414
const properties = { ...data, ...this.options.commonErrorDetails };
415
properties["error_timestamp"] = new Date().toISOString();
416
properties["error_name"] = error.name;
417
properties["error_message"] = error.message;
418
419
if (typeof window !== undefined) {
420
properties["onLine"] = String(window.navigator.onLine);
421
}
422
423
const workspaceId = properties["workspaceId"];
424
const instanceId = properties["instanceId"];
425
const userId = properties["userId"];
426
427
delete properties["workspaceId"];
428
delete properties["instanceId"];
429
delete properties["userId"];
430
431
await this.send(
432
this.create("reportError", {
433
component: this.options.clientName,
434
errorStack: error.stack ?? String(error),
435
version: this.options.clientVersion,
436
workspaceId: workspaceId ?? "",
437
instanceId: instanceId ?? "",
438
userId: userId ?? "",
439
properties,
440
}),
441
);
442
}
443
444
private create(endpoint: string, data: any): MetricsRequest | undefined {
445
try {
446
return <MetricsRequest>{
447
url: `https://${this.metricsHost}/metrics-api/` + endpoint,
448
method: "POST",
449
headers: {
450
"Content-Type": "application/json",
451
"X-Client": this.options.clientName,
452
"X-Client-Version": this.options.clientVersion,
453
},
454
body: JSON.stringify(data),
455
credentials: "omit",
456
};
457
} catch (e) {
458
this.options.log.error("metrics: failed to create request", e);
459
return undefined;
460
}
461
}
462
463
private push(request: MetricsRequest | undefined): void {
464
if (!request) {
465
return;
466
}
467
this.pendingRequests.push(request);
468
}
469
470
private async send(request: MetricsRequest | undefined): Promise<void> {
471
if (!request) {
472
return;
473
}
474
if (typeof window !== undefined && !window.navigator.onLine) {
475
this.push(request);
476
return;
477
}
478
this.sendQueue = this.sendQueue.then(async () => {
479
try {
480
const response = await fetch(request.url, { ...request, priority: "low" });
481
if (!response.ok) {
482
this.options.log.error(
483
`metrics: endpoint responded with ${response.status} ${response.statusText}`,
484
);
485
}
486
} catch (e) {
487
this.options.log.debug("metrics: failed to post, trying again next time", e);
488
this.push(request);
489
}
490
});
491
await this.sendQueue;
492
}
493
494
instrumentWebSocket(ws: WebSocket, origin: string) {
495
const inc = (status: string, code?: number, wasClean?: boolean) => {
496
metrics.webSocketCounter
497
.labels({
498
origin,
499
instance_phase: this.options.commonErrorDetails["instancePhase"],
500
status,
501
code: code !== undefined ? String(code) : undefined,
502
was_clean: wasClean !== undefined ? String(Number(wasClean)) : undefined,
503
})
504
.inc();
505
};
506
inc("new");
507
ws.addEventListener("open", () => inc("open"));
508
ws.addEventListener("error", (event) => {
509
inc("error");
510
this.reportError(new Error(`WebSocket failed: ${String(event)}`));
511
});
512
ws.addEventListener("close", (event) => {
513
inc("close", event.code, event.wasClean);
514
if (!event.wasClean) {
515
this.reportError(new Error("WebSocket was not closed cleanly"), {
516
code: String(event.code),
517
reason: event.reason,
518
});
519
}
520
});
521
}
522
}
523
524