diff --git a/ansible/roles/schulcloud-server-core/tasks/main.yml b/ansible/roles/schulcloud-server-core/tasks/main.yml index 6d17727669d..14e0e435aa3 100644 --- a/ansible/roles/schulcloud-server-core/tasks/main.yml +++ b/ansible/roles/schulcloud-server-core/tasks/main.yml @@ -322,3 +322,10 @@ template: board-collaboration-ingress.yml.j2 apply: yes state: "{{ 'present' if WITH_BOARD_COLLABORATION else 'absent'}}" + + - name: BoardCollaborationServiceMonitor + kubernetes.core.k8s: + kubeconfig: ~/.kube/config + namespace: '{{ NAMESPACE }}' + template: board-collaboration-svc-monitor.yml.j2 + state: "{{ 'present' if WITH_BOARD_COLLABORATION else 'absent'}}" diff --git a/ansible/roles/schulcloud-server-core/templates/board-collaboration-deployment.yml.j2 b/ansible/roles/schulcloud-server-core/templates/board-collaboration-deployment.yml.j2 index 4f94b759975..220bd7e6e02 100644 --- a/ansible/roles/schulcloud-server-core/templates/board-collaboration-deployment.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/board-collaboration-deployment.yml.j2 @@ -49,9 +49,9 @@ spec: - containerPort: 4450 name: websocket protocol: TCP - # - containerPort: 9090 - # name: api-metrics - # protocol: TCP + - containerPort: 9090 + name: api-metrics + protocol: TCP envFrom: - configMapRef: name: api-configmap diff --git a/ansible/roles/schulcloud-server-core/templates/board-collaboration-service.yml.j2 b/ansible/roles/schulcloud-server-core/templates/board-collaboration-service.yml.j2 index d01d0f2a652..3ae23122697 100644 --- a/ansible/roles/schulcloud-server-core/templates/board-collaboration-service.yml.j2 +++ b/ansible/roles/schulcloud-server-core/templates/board-collaboration-service.yml.j2 @@ -13,9 +13,9 @@ spec: targetPort: 4450 protocol: TCP name: websocket - #- port: {{ PORT_METRICS_SERVER }} - # targetPort: 9090 # TODO - # protocol: TCP - # name: api-metrics + - port: {{ PORT_METRICS_SERVER }} + targetPort: 9090 # TODO + protocol: TCP + name: api-metrics selector: app: board-collaboration diff --git a/ansible/roles/schulcloud-server-core/templates/board-collaboration-svc-monitor.yml.j2 b/ansible/roles/schulcloud-server-core/templates/board-collaboration-svc-monitor.yml.j2 new file mode 100644 index 00000000000..bf749d56ab3 --- /dev/null +++ b/ansible/roles/schulcloud-server-core/templates/board-collaboration-svc-monitor.yml.j2 @@ -0,0 +1,17 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: board-collaboration-svc-monitor + namespace: {{ NAMESPACE }} + labels: + app: board-collaboration +spec: + selector: + matchExpressions: + - key: app.kubernetes.io/name + operator: in + values: + - board-collaboration-svc + endpoints: + - path: /metrics + port: api-metrics diff --git a/apps/server/src/apps/board-collaboration.app.ts b/apps/server/src/apps/board-collaboration.app.ts index 770889cb362..72a44d1e061 100644 --- a/apps/server/src/apps/board-collaboration.app.ts +++ b/apps/server/src/apps/board-collaboration.app.ts @@ -8,43 +8,50 @@ import { install as sourceMapInstall } from 'source-map-support'; // application imports import { SwaggerDocumentOptions } from '@nestjs/swagger'; import { DB_URL } from '@src/config'; -import { LegacyLogger } from '@src/core/logger'; +import { LegacyLogger, Logger } from '@src/core/logger'; import { MongoIoAdapter } from '@src/infra/socketio'; import { BoardCollaborationModule } from '@src/modules/board/board-collaboration.module'; import { enableOpenApiDocs } from '@src/shared/controller/swagger'; +import express from 'express'; +import { + addPrometheusMetricsMiddlewaresIfEnabled, + createAndStartPrometheusMetricsAppIfEnabled, +} from '@src/apps/helpers/prometheus-metrics'; +import { ExpressAdapter } from '@nestjs/platform-express'; async function bootstrap() { sourceMapInstall(); - const nestApp = await NestFactory.create(BoardCollaborationModule); - - // WinstonLogger + const nestExpress = express(); + const nestExpressAdapter = new ExpressAdapter(nestExpress); + const nestApp = await NestFactory.create(BoardCollaborationModule, nestExpressAdapter); nestApp.useLogger(await nestApp.resolve(LegacyLogger)); - - // customize nest app settings nestApp.enableCors({ exposedHeaders: ['Content-Disposition'] }); const mongoIoAdapter = new MongoIoAdapter(nestApp); await mongoIoAdapter.connectToMongoDb(DB_URL); - nestApp.useWebSocketAdapter(mongoIoAdapter); const options: SwaggerDocumentOptions = { operationIdFactory: (_controllerKey: string, methodKey: string) => methodKey, }; enableOpenApiDocs(nestApp, 'docs', options); + const logger = await nestApp.resolve(Logger); await nestApp.init(); + addPrometheusMetricsMiddlewaresIfEnabled(logger, nestExpress); const port = 4450; const basePath = '/board-collaboration'; nestApp.setGlobalPrefix(basePath); - await nestApp.listen(port); + await nestApp.listen(port, () => { + createAndStartPrometheusMetricsAppIfEnabled(logger); + }); console.log('##########################################'); console.log(`### Start Board Collaboration Server ###`); - console.log(`### Port: ${port} ###`); + console.log(`### Port: ${port} ###`); console.log(`### Base path: ${basePath} ###`); console.log('##########################################'); } diff --git a/apps/server/src/modules/authentication/index.ts b/apps/server/src/modules/authentication/index.ts index 30b503e11c5..07a4ce9a7ae 100644 --- a/apps/server/src/modules/authentication/index.ts +++ b/apps/server/src/modules/authentication/index.ts @@ -3,4 +3,5 @@ export { Authenticate, CurrentUser, JWT } from './decorator'; export { ICurrentUser } from './interface'; export { AuthenticationService } from './services'; export { XApiKeyConfig } from './config'; +export { WsJwtAuthGuard } from './guard/ws-jwt-auth.guard'; export { AuthenticationConfig } from './authentication-config'; diff --git a/apps/server/src/modules/board/board-ws-api.module.ts b/apps/server/src/modules/board/board-ws-api.module.ts index 2883c658eda..74b5a2b45b2 100644 --- a/apps/server/src/modules/board/board-ws-api.module.ts +++ b/apps/server/src/modules/board/board-ws-api.module.ts @@ -1,15 +1,26 @@ import { forwardRef, Module } from '@nestjs/common'; import { CourseRepo } from '@shared/repo'; import { LoggerModule } from '@src/core/logger'; -import { AuthorizationModule } from '../authorization'; +import { AuthorizationModule } from '@modules/authorization'; +import { UserModule } from '@modules/user'; import { BoardModule } from './board.module'; import { BoardCollaborationGateway } from './gateway/board-collaboration.gateway'; +import { MetricsService } from './metrics/metrics.service'; import { BoardUc, CardUc, ColumnUc, ElementUc } from './uc'; import { BoardNodePermissionService } from './service'; @Module({ - imports: [BoardModule, forwardRef(() => AuthorizationModule), LoggerModule], - providers: [BoardCollaborationGateway, BoardNodePermissionService, CardUc, ColumnUc, ElementUc, BoardUc, CourseRepo], + imports: [BoardModule, forwardRef(() => AuthorizationModule), LoggerModule, UserModule], + providers: [ + BoardCollaborationGateway, + BoardNodePermissionService, + CardUc, + ColumnUc, + ElementUc, + BoardUc, + CourseRepo, + MetricsService, + ], exports: [], }) export class BoardWsApiModule {} diff --git a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts index a9abebe4625..a647be3ef08 100644 --- a/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts +++ b/apps/server/src/modules/board/gateway/board-collaboration.gateway.ts @@ -1,14 +1,23 @@ -import { WsValidationPipe, Socket } from '@infra/socketio'; +import { Socket, WsValidationPipe } from '@infra/socketio'; import { MikroORM, UseRequestContext } from '@mikro-orm/core'; +import { WsJwtAuthGuard } from '@modules/authentication'; import { UseGuards, UsePipes } from '@nestjs/common'; -import { SubscribeMessage, WebSocketGateway, WsException } from '@nestjs/websockets'; -import { WsJwtAuthGuard } from '@modules/authentication/guard/ws-jwt-auth.guard'; +import { + OnGatewayDisconnect, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, + WsException, +} from '@nestjs/websockets'; +import { Server } from 'socket.io'; import { BoardResponseMapper, CardResponseMapper, ColumnResponseMapper, ContentElementResponseFactory, } from '../controller/mapper'; +import { MetricsService } from '../metrics/metrics.service'; +import { TrackExecutionTime } from '../metrics/track-execution-time.decorator'; import { BoardNodeAuthorizableService } from '../service'; import { BoardUc, CardUc, ColumnUc, ElementUc } from '../uc'; import { @@ -36,22 +45,46 @@ import { UpdateContentElementMessageParams } from './dto/update-content-element. @UsePipes(new WsValidationPipe()) @WebSocketGateway(BoardCollaborationConfiguration.websocket) @UseGuards(WsJwtAuthGuard) -export class BoardCollaborationGateway { +export class BoardCollaborationGateway implements OnGatewayDisconnect { + @WebSocketServer() + server!: Server; + + // TODO: use loggables instead of legacy logger constructor( private readonly orm: MikroORM, private readonly boardUc: BoardUc, private readonly columnUc: ColumnUc, private readonly cardUc: CardUc, private readonly elementUc: ElementUc, + private readonly metricsService: MetricsService, private readonly authorizableService: BoardNodeAuthorizableService // to be removed ) {} + trackExecutionTime(methodName: string, executionTimeMs: number) { + if (this.metricsService) { + this.metricsService.setExecutionTime(methodName, executionTimeMs); + } + } + private getCurrentUser(socket: Socket) { const { user } = socket.handshake; if (!user) throw new WsException('Not Authenticated.'); return user; } + private async updateRoomsAndUsersMetrics(socket: Socket) { + const roomCount = Array.from(this.server.of('/').adapter.rooms.keys()).filter((key) => + key.startsWith('board_') + ).length; + this.metricsService.setNumberOfBoardRooms(roomCount); + const { user } = socket.handshake; + await this.metricsService.trackRoleOfClient(socket.id, user?.userId); + } + + public handleDisconnect(socket: Socket): void { + this.metricsService.untrackClient(socket.id); + } + @SubscribeMessage('delete-board-request') @UseRequestContext() async deleteBoard(socket: Socket, data: DeleteBoardMessageParams) { @@ -64,9 +97,11 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('update-board-title-request') + @TrackExecutionTime() @UseRequestContext() async updateBoardTitle(socket: Socket, data: UpdateBoardTitleMessageParams) { const emitter = await this.buildBoardSocketEmitter({ socket, id: data.boardId, action: 'update-board-title' }); @@ -78,9 +113,11 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('update-card-title-request') + @TrackExecutionTime() @UseRequestContext() async updateCardTitle(socket: Socket, data: UpdateCardTitleMessageParams) { const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardId, action: 'update-card-title' }); @@ -92,6 +129,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('update-card-height-request') @@ -106,6 +144,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('delete-card-request') @@ -120,9 +159,11 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('create-card-request') + @TrackExecutionTime() @UseRequestContext() async createCard(socket: Socket, data: CreateCardMessageParams) { const emitter = await this.buildBoardSocketEmitter({ socket, id: data.columnId, action: 'create-card' }); @@ -140,6 +181,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('create-column-request') @@ -167,6 +209,7 @@ export class BoardCollaborationGateway { } @SubscribeMessage('fetch-board-request') + @TrackExecutionTime() @UseRequestContext() async fetchBoard(socket: Socket, data: FetchBoardMessageParams) { const emitter = await this.buildBoardSocketEmitter({ socket, id: data.boardId, action: 'fetch-board' }); @@ -179,6 +222,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('move-card-request') @@ -193,6 +237,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('move-column-request') @@ -207,9 +252,11 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('update-column-title-request') + @TrackExecutionTime() @UseRequestContext() async updateColumnTitle(socket: Socket, data: UpdateColumnTitleMessageParams) { const emitter = await this.buildBoardSocketEmitter({ socket, id: data.columnId, action: 'update-column-title' }); @@ -221,6 +268,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('update-board-visibility-request') @@ -235,6 +283,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('delete-column-request') @@ -249,9 +298,11 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('fetch-card-request') + @TrackExecutionTime() @UseRequestContext() async fetchCards(socket: Socket, data: FetchCardsMessageParams) { const emitter = await this.buildBoardSocketEmitter({ socket, id: data.cardIds[0], action: 'fetch-card' }); @@ -264,6 +315,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('create-element-request') @@ -282,9 +334,11 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('update-element-request') + @TrackExecutionTime() @UseRequestContext() async updateElement(socket: Socket, data: UpdateContentElementMessageParams) { const emitter = await this.buildBoardSocketEmitter({ socket, id: data.elementId, action: 'update-element' }); @@ -296,6 +350,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('delete-element-request') @@ -310,6 +365,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } @SubscribeMessage('move-element-request') @@ -324,6 +380,7 @@ export class BoardCollaborationGateway { } catch (err) { emitter.emitFailure(data); } + await this.updateRoomsAndUsersMetrics(socket); } private async buildBoardSocketEmitter({ socket, id, action }: { socket: Socket; id: string; action: string }) { diff --git a/apps/server/src/modules/board/metrics/metrics.service.spec.ts b/apps/server/src/modules/board/metrics/metrics.service.spec.ts new file mode 100644 index 00000000000..9da48af0baa --- /dev/null +++ b/apps/server/src/modules/board/metrics/metrics.service.spec.ts @@ -0,0 +1,87 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { Test, TestingModule } from '@nestjs/testing'; +import { UserService } from '@src/modules/user'; +import { RoleName } from '@shared/domain/interface'; +import { roleFactory, userDoFactory } from '@shared/testing'; +import { MetricsService } from './metrics.service'; + +describe(MetricsService.name, () => { + let module: TestingModule; + let service: MetricsService; + let userService: DeepMocked; + + beforeAll(async () => { + module = await Test.createTestingModule({ + providers: [ + MetricsService, + { + provide: UserService, + useValue: createMock(), + }, + ], + }).compile(); + + service = module.get(MetricsService); + userService = module.get(UserService); + }); + + afterAll(async () => { + await module.close(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('trackRoleOfClient', () => { + const setup = (roleName: RoleName) => { + const teacherRole = roleFactory.build({ name: roleName }); + const userDo = userDoFactory.buildWithId({ roles: [teacherRole] }); + userService.findById.mockResolvedValueOnce(userDo); + const clientId = Math.random().toString(); + return { userDo, clientId, userId: userDo.id }; + }; + + describe('when tracking a user with role teacher', () => { + it('should count one editor', async () => { + const { clientId, userId } = setup(RoleName.TEACHER); + + const role = await service.trackRoleOfClient(clientId, userId); + + expect(role).toEqual('editor'); + }); + }); + + describe('when tracking a user with role Course-Substition-Teacher', () => { + it('should count one editor', async () => { + const { clientId, userId } = setup(RoleName.COURSESUBSTITUTIONTEACHER); + + const role = await service.trackRoleOfClient(clientId, userId); + + expect(role).toEqual('editor'); + }); + }); + + describe('when tracking a user with role student', () => { + it('should count one viewer', async () => { + const { clientId, userId } = setup(RoleName.STUDENT); + + const role = await service.trackRoleOfClient(clientId, userId); + + expect(role).toEqual('viewer'); + }); + }); + + describe('when tracking a user with role teacher that is unknown', () => { + it('should not count for any role', async () => { + const teacherRole = roleFactory.build({ name: RoleName.TEACHER }); + const userDo = userDoFactory.buildWithId({ roles: [teacherRole] }); + const clientId = Math.random().toString(); + + const role = await service.trackRoleOfClient(clientId, userDo.id); + + expect(role).toEqual(undefined); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/metrics/metrics.service.ts b/apps/server/src/modules/board/metrics/metrics.service.ts new file mode 100644 index 00000000000..8019220b40e --- /dev/null +++ b/apps/server/src/modules/board/metrics/metrics.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import { UserDO } from '@shared/domain/domainobject'; +import { RoleName } from '@shared/domain/interface'; +import { UserService } from '@src/modules/user'; +import { Gauge, Summary, register } from 'prom-client'; + +type ClientId = string; +type Role = 'owner' | 'editor' | 'viewer'; + +@Injectable() +export class MetricsService { + private knownClientRoles: Map = new Map(); + + private numberOfBoardroomsOnServerCounter: Gauge; + + public numberOfEditorsOnServerCounter: Gauge; + + private numberOfViewersOnServerCounter: Gauge; + + private executionTimesSummary: Map> = new Map(); + + constructor(private readonly userService: UserService) { + this.numberOfBoardroomsOnServerCounter = new Gauge({ + name: 'sc_boards_rooms', + help: 'Number of active boards per pod', + }); + + this.numberOfEditorsOnServerCounter = new Gauge({ + name: 'sc_boards_editors', + help: 'Number of active editors per pod', + }); + + this.numberOfViewersOnServerCounter = new Gauge({ + name: 'sc_boards_viewers', + help: 'Number of active viewers per pod', + }); + + register.registerMetric(this.numberOfEditorsOnServerCounter); + register.registerMetric(this.numberOfViewersOnServerCounter); + register.registerMetric(this.numberOfBoardroomsOnServerCounter); + } + + private mapRole(user: UserDO): 'editor' | 'viewer' | undefined { + const EDITOR_ROLES = [RoleName.TEACHER, RoleName.COURSESUBSTITUTIONTEACHER, RoleName.COURSETEACHER]; + if (user.roles.find((r) => EDITOR_ROLES.includes(r.name))) { + return 'editor'; + } + return 'viewer'; + } + + public async trackRoleOfClient(clientId: ClientId, userId: string | undefined): Promise { + let role = this.knownClientRoles.get(clientId); + if (role === undefined && userId) { + const userDo = await this.userService.findById(userId); + if (userDo) { + role = this.mapRole(userDo); + if (role) { + this.knownClientRoles.set(clientId, role); + this.updateRoleCounts(); + } + } + } + return role; + } + + public untrackClient(clientId: ClientId): void { + this.knownClientRoles.delete(clientId); + this.updateRoleCounts(); + } + + private updateRoleCounts(): void { + this.numberOfEditorsOnServerCounter.set(this.countByRole('editor')); + this.numberOfViewersOnServerCounter.set(this.countByRole('viewer')); + } + + private countByRole(role: Role) { + return Array.from(this.knownClientRoles.values()).filter((r) => r === role).length; + } + + public setNumberOfBoardRooms(value: number): void { + this.numberOfBoardroomsOnServerCounter.set(value); + } + + public setExecutionTime(actionName: string, value: number): void { + let summary = this.executionTimesSummary.get(actionName); + + if (!summary) { + summary = new Summary({ + name: `sc_boards_execution_time_${actionName}`, + help: 'Average execution time of a specific action in milliseconds', + maxAgeSeconds: 60, + ageBuckets: 5, + percentiles: [0.01, 0.1, 0.9, 0.99], + }); + this.executionTimesSummary.set(actionName, summary); + register.registerMetric(summary); + } + summary.observe(value); + } +} diff --git a/apps/server/src/modules/board/metrics/track-execution-time.decorator.spec.ts b/apps/server/src/modules/board/metrics/track-execution-time.decorator.spec.ts new file mode 100644 index 00000000000..76e3cb98624 --- /dev/null +++ b/apps/server/src/modules/board/metrics/track-execution-time.decorator.spec.ts @@ -0,0 +1,36 @@ +import { TrackExecutionTime } from './track-execution-time.decorator'; + +class MockClassWithTrackingFunction { + trackExecutionTime(methodName: string, executionTime: number) { + console.log(`Executing method ${methodName} took ${executionTime}ms`); + } +} +class MockClassWithoutTrackingFunction { + hello() { + console.log('Hello'); + } +} + +describe('TrackExecutionTimeDecorator', () => { + describe('track', () => { + describe('when a tracking function is defined in the target object', () => { + it('should not throw an exception', () => { + const decorator = TrackExecutionTime(); + + const target = new MockClassWithTrackingFunction(); + expect(() => decorator(target, 'nameOfFunctionBeingTracked', {})).not.toThrow(); + }); + }); + + describe('when no tracking function is defined in the target object', () => { + it('should throw an exception', () => { + const decorator = TrackExecutionTime(); + + const target = new MockClassWithoutTrackingFunction(); + expect(() => decorator(target, 'nameOfFunctionBeingTracked', {})).toThrowError( + `The class MockClassWithoutTrackingFunction does not implement the required trackExecutionTime method.` + ); + }); + }); + }); +}); diff --git a/apps/server/src/modules/board/metrics/track-execution-time.decorator.ts b/apps/server/src/modules/board/metrics/track-execution-time.decorator.ts new file mode 100644 index 00000000000..2ae058a488d --- /dev/null +++ b/apps/server/src/modules/board/metrics/track-execution-time.decorator.ts @@ -0,0 +1,26 @@ +import { performance } from 'perf_hooks'; + +const CALLBACK_METHOD_NAME = 'trackExecutionTime'; + +export function TrackExecutionTime(): MethodDecorator { + return function track(target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) { + if (typeof target[CALLBACK_METHOD_NAME] !== 'function') { + throw new Error( + `The class ${target.constructor.name} does not implement the required ${CALLBACK_METHOD_NAME} method.` + ); + } + + const originalMethod = descriptor.value as () => unknown; + descriptor.value = async function wrapper(...args: []) { + const startTime = performance.now(); + const result = await originalMethod.apply(this, args); + const executionTime = performance.now() - startTime; + + const callback = target[CALLBACK_METHOD_NAME] as (methodName: string, executionTime: number) => void; + callback.apply(this, [String(propertyKey), executionTime]); + + return result; + }; + return descriptor; + }; +}