diff --git a/components/gitpod-protocol/package.json b/components/gitpod-protocol/package.json index 6b2e233ffca528..68fb90cc638cff 100644 --- a/components/gitpod-protocol/package.json +++ b/components/gitpod-protocol/package.json @@ -10,11 +10,12 @@ "src" ], "devDependencies": { + "@grpc/grpc-js": "^1.3.7", "@types/analytics-node": "^3.1.9", "@types/chai-subset": "^1.3.3", "@types/cookie": "^0.4.1", "@types/express": "^4.17.13", - "@grpc/grpc-js": "^1.3.7", + "@types/google-protobuf": "^3.15.5", "@types/jaeger-client": "^3.18.3", "@types/js-yaml": "^3.10.1", "@types/mocha": "^5.2.7", @@ -22,7 +23,6 @@ "@types/random-number-csprng": "^1.0.0", "@types/uuid": "^8.3.1", "@types/ws": "^5.1.2", - "@types/google-protobuf": "^3.15.5", "chai": "^4.3.4", "chai-subset": "^1.6.0", "mocha": "^5.0.0", @@ -42,14 +42,17 @@ }, "dependencies": { "@types/react": "17.0.32", + "abort-controller-x": "^0.4.0", "ajv": "^6.5.4", "analytics-node": "^6.0.0", "configcat-node": "^8.0.0", "cookie": "^0.4.2", "express": "^4.17.3", + "google-protobuf": "^3.19.1", "inversify": "^5.1.1", "jaeger-client": "^3.18.1", "js-yaml": "^3.10.0", + "nice-grpc-common": "^2.0.0", "opentracing": "^0.14.5", "prom-client": "^13.2.0", "random-number-csprng": "^1.0.2", @@ -63,7 +66,6 @@ "vscode-languageserver-types": "3.17.0", "vscode-uri": "^3.0.3", "vscode-ws-jsonrpc": "^0.2.0", - "ws": "^7.4.6", - "google-protobuf": "^3.19.1" + "ws": "^7.4.6" } } diff --git a/components/gitpod-protocol/src/util/nice-grpc.ts b/components/gitpod-protocol/src/util/nice-grpc.ts new file mode 100644 index 00000000000000..ca6fcf1c30b493 --- /dev/null +++ b/components/gitpod-protocol/src/util/nice-grpc.ts @@ -0,0 +1,93 @@ +/** + * Copyright (c) 2022 Gitpod GmbH. All rights reserved. + * Licensed under the GNU Affero General Public License (AGPL). + * See License-AGPL.txt in the project root for license information. + */ + +import { isAbortError } from "abort-controller-x"; +import { + CallOptions, + ClientError, + ClientMiddleware, + ClientMiddlewareCall, + Status, + MethodDescriptor, +} from "nice-grpc-common"; +import { GrpcMethodType, IClientCallMetrics } from "./grpc"; + +function getLabels(method: MethodDescriptor) { + const callType = method.requestStream + ? method.responseStream + ? "bidi_stream" + : "client_stream" + : method.responseStream + ? "server_stream" + : "unary"; + const { path } = method; + const [serviceName, methodName] = path.split("/").slice(1); + + return { + type: callType as GrpcMethodType, + service: serviceName, + method: methodName, + }; +} + +async function* incrementStreamMessagesCounter(iterable: AsyncIterable, callback: () => void): AsyncIterable { + for await (const item of iterable) { + callback(); + yield item; + } +} + +export function prometheusClientMiddleware(metrics: IClientCallMetrics): ClientMiddleware { + return async function* prometheusClientMiddlewareGenerator( + call: ClientMiddlewareCall, + options: CallOptions, + ): AsyncGenerator { + const labels = getLabels(call.method); + + metrics.started(labels); + + let settled = false; + let status: Status = Status.OK; + + try { + let request; + + if (!call.requestStream) { + request = call.request; + } else { + request = incrementStreamMessagesCounter(call.request, metrics.sent.bind(metrics, labels)); + } + + if (!call.responseStream) { + const response = yield* call.next(request, options); + settled = true; + return response; + } else { + yield* incrementStreamMessagesCounter( + call.next(request, options), + metrics.received.bind(metrics, labels), + ); + settled = true; + return; + } + } catch (err) { + settled = true; + if (err instanceof ClientError) { + status = err.code; + } else if (isAbortError(err)) { + status = Status.CANCELLED; + } else { + status = Status.UNKNOWN; + } + throw err; + } finally { + if (!settled) { + status = Status.CANCELLED; + } + metrics.handled({ ...labels, code: Status[status] }); + } + }; +} diff --git a/components/server/src/container-module.ts b/components/server/src/container-module.ts index c09eae7fd4b7ce..7c73cb694b02ff 100644 --- a/components/server/src/container-module.ts +++ b/components/server/src/container-module.ts @@ -101,7 +101,7 @@ import { LicenseEvaluator } from "@gitpod/licensor/lib"; import { WorkspaceClusterImagebuilderClientProvider } from "./workspace/workspace-cluster-imagebuilder-client-provider"; import { UsageServiceClient, UsageServiceDefinition } from "@gitpod/usage-api/lib/usage/v1/usage.pb"; import { BillingServiceClient, BillingServiceDefinition } from "@gitpod/usage-api/lib/usage/v1/billing.pb"; -import { createChannel, createClient } from "nice-grpc"; +import { createChannel, createClient, createClientFactory } from "nice-grpc"; import { CommunityEntitlementService, EntitlementService } from "./billing/entitlement-service"; import { ConfigCatClientFactory, @@ -111,6 +111,7 @@ import { VerificationService } from "./auth/verification-service"; import { WebhookEventGarbageCollector } from "./projects/webhook-event-garbage-collector"; import { LivenessController } from "./liveness/liveness-controller"; import { IDEServiceClient, IDEServiceDefinition } from "@gitpod/ide-service-api/lib/ide.pb"; +import { prometheusClientMiddleware } from "@gitpod/gitpod-protocol/lib/util/nice-grpc"; export const productionContainerModule = new ContainerModule((bind, unbind, isBound, rebind) => { bind(Config).toConstantValue(ConfigFile.fromFile()); @@ -271,7 +272,10 @@ export const productionContainerModule = new ContainerModule((bind, unbind, isBo bind(IDEServiceDefinition.name).toDynamicValue((ctx) => { const config = ctx.container.get(Config); - return createClient(IDEServiceDefinition, createChannel(config.ideServiceAddr)); + const metricsClient = ctx.container.get(IClientCallMetrics); + return createClientFactory() + .use(prometheusClientMiddleware(metricsClient)) + .create(IDEServiceDefinition, createChannel(config.ideServiceAddr)); }); bind(EntitlementService).to(CommunityEntitlementService).inSingletonScope();