From 0f2fc82541189136c61a57b18fcd7bd22012acd2 Mon Sep 17 00:00:00 2001 From: Envyw6567 Date: Sat, 23 Nov 2024 13:20:44 +0900 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=AF=B8=20=ED=86=B5?= =?UTF-8?q?=EC=A0=95=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/clickhouse/util/date-range.ts | 9 ----- .../src/log/log.contorller.spec.ts | 6 +-- .../src/log/log.repository.spec.ts | 2 +- .../src/log/log.service.spec.ts | 40 +++++++++---------- backend/console-server/src/log/log.service.ts | 2 +- .../src/project/project.service.spec.ts | 10 ++--- 6 files changed, 28 insertions(+), 41 deletions(-) delete mode 100644 backend/console-server/src/clickhouse/util/date-range.ts diff --git a/backend/console-server/src/clickhouse/util/date-range.ts b/backend/console-server/src/clickhouse/util/date-range.ts deleted file mode 100644 index d32fa8eb..00000000 --- a/backend/console-server/src/clickhouse/util/date-range.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const getDateRange = (days: number = 7) => { - const end = new Date(); - const start = new Date(end.getTime() - days * 24 * 60 * 60 * 1000); - - return { - start, - end, - }; -}; diff --git a/backend/console-server/src/log/log.contorller.spec.ts b/backend/console-server/src/log/log.contorller.spec.ts index 04a70c49..982ae27d 100644 --- a/backend/console-server/src/log/log.contorller.spec.ts +++ b/backend/console-server/src/log/log.contorller.spec.ts @@ -17,9 +17,8 @@ describe('LogController 테스트', () => { let service: LogService; const mockLogService = { - httpLog: jest.fn(), getAvgElapsedTime: jest.fn(), - trafficRank: jest.fn(), + getTrafficRank: jest.fn(), getResponseSuccessRate: jest.fn(), getResponseSuccessRateByProject: jest.fn(), getTrafficByGeneration: jest.fn(), @@ -60,7 +59,6 @@ describe('LogController 테스트', () => { it('평균 응답 시간을 ProjectResponseDto 형식으로 반환해야 한다', async () => { mockLogService.getAvgElapsedTime.mockResolvedValue(mockResult); - const result = await controller.getElapsedTime( plainToInstance(GetAvgElapsedTimeDto, { generation: 1 }), ); @@ -85,7 +83,7 @@ describe('LogController 테스트', () => { }; it('TOP 5 트래픽 순위를 ProjectResponseDto 형식으로 반환해야 한다', async () => { - mockLogService.trafficRank.mockResolvedValue(mockResult); + mockLogService.getTrafficRank.mockResolvedValue(mockResult); const result = (await controller.getTrafficRank( plainToInstance(GetTrafficRankDto, { generation: 1 }), diff --git a/backend/console-server/src/log/log.repository.spec.ts b/backend/console-server/src/log/log.repository.spec.ts index 5287508e..a7ad563b 100644 --- a/backend/console-server/src/log/log.repository.spec.ts +++ b/backend/console-server/src/log/log.repository.spec.ts @@ -130,8 +130,8 @@ describe('LogRepository 테스트', () => { describe('findTrafficByGeneration()는 ', () => { it('전체 트래픽 수를 반환해야 한다.', async () => { const mockResult = { count: 5000 }; - mockClickhouse.query.mockResolvedValue(mockResult); + mockClickhouse.query.mockResolvedValue([mockResult]); const result = await repository.findTrafficByGeneration(); expect(result).toEqual(mockResult); diff --git a/backend/console-server/src/log/log.service.spec.ts b/backend/console-server/src/log/log.service.spec.ts index 3adf2a87..40f551a4 100644 --- a/backend/console-server/src/log/log.service.spec.ts +++ b/backend/console-server/src/log/log.service.spec.ts @@ -10,13 +10,15 @@ import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; import { GetSuccessRateByProjectResponseDto } from './dto/get-success-rate-by-project-response.dto'; import type { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; +import { plainToInstance } from 'class-transformer'; +import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; +import { GetTrafficRankDto } from './dto/get-traffic-rank.dto'; describe('LogService 테스트', () => { let service: LogService; let repository: LogRepository; const mockLogRepository = { - findHttpLog: jest.fn(), findAvgElapsedTime: jest.fn(), findTop5CountByHost: jest.fn(), findResponseSuccessRate: jest.fn(), @@ -60,7 +62,9 @@ describe('LogService 테스트', () => { const mockTime = { avg_elapsed_time: 150 }; mockLogRepository.findAvgElapsedTime.mockResolvedValue(mockTime); - const result = await service.getAvgElapsedTime(); + const result = await service.getAvgElapsedTime( + plainToInstance(GetAvgElapsedTimeDto, { generation: 9 }), + ); expect(result).toEqual(mockTime); expect(repository.findAvgElapsedTime).toHaveBeenCalled(); @@ -68,18 +72,19 @@ describe('LogService 테스트', () => { }); describe('trafficRank()는 ', () => { - it('top 5 traffic ranks를 리턴할 수 있어야 한다.', async () => { + it('top 5 traffic rank를 리턴할 수 있어야 한다.', async () => { const mockRanks = [ { host: 'api1.example.com', count: 1000 }, { host: 'api2.example.com', count: 800 }, { host: 'api3.example.com', count: 600 }, { host: 'api4.example.com', count: 400 }, { host: 'api5.example.com', count: 200 }, - { host: 'api6.example.com', count: 110 }, ]; - mockLogRepository.findCountByHost.mockResolvedValue(mockRanks); + mockLogRepository.findTop5CountByHost.mockResolvedValue(mockRanks); - const result = await service.getTrafficRank(); + const result = await service.getTrafficRank( + plainToInstance(GetTrafficRankDto, { generation: 9 }), + ); expect(result).toHaveLength(5); expect(result).toEqual(mockRanks.slice(0, 5)); @@ -91,9 +96,11 @@ describe('LogService 테스트', () => { { host: 'api1.example.com', count: 1000 }, { host: 'api2.example.com', count: 800 }, ]; - mockLogRepository.findCountByHost.mockResolvedValue(mockRanks); + mockLogRepository.findTop5CountByHost.mockResolvedValue(mockRanks); - const result = await service.getTrafficRank(); + const result = await service.getTrafficRank( + plainToInstance(GetTrafficRankDto, { generation: 9 }), + ); expect(result).toHaveLength(2); expect(result).toEqual(mockRanks); @@ -201,22 +208,15 @@ describe('LogService 테스트', () => { describe('trafficByGeneration()는 ', () => { it('기수별 트래픽의 총합을 올바르게 반환할 수 있어야 한다.', async () => { - const mockStats = [ - { generation: '10s', count: 500 }, - { generation: '20s', count: 300 }, - { generation: '30s', count: 200 }, - ]; - const expectedTotalCount = mockStats.reduce((sum, stat) => sum + stat.count, 0); - const dto = new GetTrafficByGenerationDto(); - dto.generation = 9; - - const mockRepositoryResponse = [{ count: expectedTotalCount }]; + const mockRepositoryResponse = { count: 1000 }; const expectedResponse: GetTrafficByGenerationResponseDto = { - count: expectedTotalCount, + count: 1000, }; mockLogRepository.findTrafficByGeneration.mockResolvedValue(mockRepositoryResponse); - const result = await service.getTrafficByGeneration(dto); + const result = await service.getTrafficByGeneration( + plainToInstance(GetTrafficByGenerationDto, { generation: 9 }), + ); expect(result).toEqual(expectedResponse); expect(mockLogRepository.findTrafficByGeneration).toHaveBeenCalledTimes(1); diff --git a/backend/console-server/src/log/log.service.ts b/backend/console-server/src/log/log.service.ts index a270b7d9..19a342ce 100644 --- a/backend/console-server/src/log/log.service.ts +++ b/backend/console-server/src/log/log.service.ts @@ -214,7 +214,7 @@ export class LogService { return plainToInstance(GetSpeedRankResponseDto, response); } - + async getTrafficTop5Chart(_getTrafficTop5ChartDto: GetTrafficTop5ChartDto) { const results = await this.logRepository.findTrafficTop5Chart(); diff --git a/backend/console-server/src/project/project.service.spec.ts b/backend/console-server/src/project/project.service.spec.ts index 9b6f90c5..8393a838 100644 --- a/backend/console-server/src/project/project.service.spec.ts +++ b/backend/console-server/src/project/project.service.spec.ts @@ -9,7 +9,7 @@ import { QueryFailedError } from 'typeorm'; import type { CreateProjectDto } from './dto/create-project.dto'; import { ProjectResponseDto } from './dto/create-project-response.dto'; import { ConflictException } from '@nestjs/common'; -import type { FindByGenerationDto } from './dto/find-by-generation.dto'; +import { FindByGenerationDto } from './dto/find-by-generation.dto'; import { FindByGenerationResponseDto } from './dto/find-by-generation-response.dto'; import { plainToInstance } from 'class-transformer'; @@ -100,9 +100,7 @@ describe('ProjectService 클래스의', () => { it('특정 기수의 프로젝트 이름 목록을 반환합니다.', async () => { // Given const generation = 1; - const findGenerationProjectDto: FindByGenerationDto = { - generation, - }; + const findByGenerationDto = plainToInstance(FindByGenerationDto, { generation }); const mockProjects = [ { name: 'Project A' }, @@ -110,13 +108,13 @@ describe('ProjectService 클래스의', () => { { name: 'Project C' }, ] as Project[]; const expectedResponse = mockProjects.map((p) => - plainToInstance(FindByGenerationResponseDto, p.name), + plainToInstance(FindByGenerationResponseDto, { value: p.name }), ); (projectRepository.find as jest.Mock).mockResolvedValue(mockProjects); // When - const result = await projectService.findByGeneration(findGenerationProjectDto); + const result = await projectService.findByGeneration(findByGenerationDto); // Then expect(projectRepository.find).toHaveBeenCalledWith({ From d897f5031b35d208cb4a2c0d0f055dc5f070743b Mon Sep 17 00:00:00 2001 From: Envyw6567 Date: Sat, 23 Nov 2024 15:04:30 +0900 Subject: [PATCH 02/16] =?UTF-8?q?test:=20TimeSeriesQueryBuilder=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/clickhouse/clickhouse.ts | 8 +- .../src/clickhouse/core/clickhouse.error.ts | 16 + .../time-series-query-builder.error.ts | 8 + .../time-series.query-builder.spec.ts | 288 +++++++++++++++++ .../time-series.query-builder.ts | 296 +++++++++++++++--- .../util/clickhouse-client.error.ts | 8 + .../clickhouse/util/map-filter-condition.ts | 19 -- .../src/clickhouse/util/metric-expressions.ts | 6 +- .../src/log/log.repository.spec.ts | 6 +- .../console-server/src/log/log.repository.ts | 3 +- backend/console-server/src/main.ts | 4 +- 11 files changed, 587 insertions(+), 75 deletions(-) create mode 100644 backend/console-server/src/clickhouse/core/clickhouse.error.ts create mode 100644 backend/console-server/src/clickhouse/query-builder/time-series-query-builder.error.ts create mode 100644 backend/console-server/src/clickhouse/query-builder/time-series.query-builder.spec.ts create mode 100644 backend/console-server/src/clickhouse/util/clickhouse-client.error.ts diff --git a/backend/console-server/src/clickhouse/clickhouse.ts b/backend/console-server/src/clickhouse/clickhouse.ts index 3cf83757..239ac6fe 100644 --- a/backend/console-server/src/clickhouse/clickhouse.ts +++ b/backend/console-server/src/clickhouse/clickhouse.ts @@ -2,6 +2,7 @@ import { createClient } from '@clickhouse/client'; import { NodeClickHouseClient } from '@clickhouse/client/dist/client'; import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { ClickhouseClientError } from './util/clickhouse-client.error'; @Injectable() export class Clickhouse implements OnModuleInit, OnModuleDestroy { @@ -49,7 +50,9 @@ export class Clickhouse implements OnModuleInit, OnModuleDestroy { const config = this.configService.get('clickhouse'); this.client = createClient(config.clickhouse); } catch (error) { - throw new Error(`Failed to initialize ClickHouse client: ${error.message}`); + throw new ClickhouseClientError( + `Failed to initialize ClickHouse client: ${error.message}`, + ); } } @@ -97,8 +100,7 @@ export class Clickhouse implements OnModuleInit, OnModuleDestroy { return await resultSet.json(); } catch (error) { - this.logger.error(`Query failed: ${error.message}`); - throw error; + throw new ClickhouseClientError('Query failed:', error); } } } diff --git a/backend/console-server/src/clickhouse/core/clickhouse.error.ts b/backend/console-server/src/clickhouse/core/clickhouse.error.ts new file mode 100644 index 00000000..7f5a1cc3 --- /dev/null +++ b/backend/console-server/src/clickhouse/core/clickhouse.error.ts @@ -0,0 +1,16 @@ +import { Logger } from '@nestjs/common'; + +export class ClickhouseError extends Error { + constructor( + message: string, + originalError?: Error, + private readonly logger = new Logger(ClickhouseError.name, { timestamp: true }), + ) { + super(message); + + this.logger.error( + `${this.name}: ${message}`, + originalError ? { originalError } : undefined, + ); + } +} diff --git a/backend/console-server/src/clickhouse/query-builder/time-series-query-builder.error.ts b/backend/console-server/src/clickhouse/query-builder/time-series-query-builder.error.ts new file mode 100644 index 00000000..85a18204 --- /dev/null +++ b/backend/console-server/src/clickhouse/query-builder/time-series-query-builder.error.ts @@ -0,0 +1,8 @@ +import { ClickhouseError } from '../core/clickhouse.error'; + +export class TimeSeriesQueryBuilderError extends ClickhouseError { + constructor(message: string, error?: Error) { + super(message, error); + super.name = 'TimeSeriesQueryBuilderError'; + } +} diff --git a/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.spec.ts b/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.spec.ts new file mode 100644 index 00000000..22fbcb03 --- /dev/null +++ b/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.spec.ts @@ -0,0 +1,288 @@ +import { TimeSeriesQueryBuilder } from './time-series.query-builder'; +import { TimeSeriesQueryBuilderError } from './time-series-query-builder.error'; + +describe('TimeSeriesBuilder의', () => { + let builder: TimeSeriesQueryBuilder; + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + builder = new TimeSeriesQueryBuilder(); + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + describe('interval() 메서드는', () => { + it('주어진 interval 시간을 쿼리문에 추가해야한다', () => { + const result = builder.interval('1 MINUTE').build(); + expect(result.query).toContain('toStartOf1 MINUTE(timestamp) as timestamp'); + }); + }); + + describe('metrics() 메서드는', () => { + it('집계함수가 없는 메트릭을 쿼리문에 추가해야한다', () => { + const metrics = [{ name: 'cpu_usage' }]; + const result = builder.metrics(metrics).build(); + expect(result.query).toContain('cpu_usage'); + }); + + it('집계함수가 있는 메트릭을 쿼리문에 추가해야한다', () => { + const metrics = [{ name: 'cpu_usage', aggregation: 'avg' }]; + const result = builder.metrics(metrics).build(); + expect(result.query).toContain('avg(cpu_usage)'); + }); + + it('올바르지 않은 집계함수에 대해 에러를 발생시켜야한다', () => { + const metrics = [{ name: 'cpu_usage', aggregation: 'notSupported' }]; + expect(() => builder.metrics(metrics).build()).toThrow(TimeSeriesQueryBuilderError); + }); + }); + + describe('from() 메서드는', () => { + it('FROM 절을 쿼리문에 추가해야한다', () => { + const result = builder.from('metrics_table').build(); + expect(result.query).toContain('FROM metrics_table'); + }); + + it('테이블명이 비어있으면 에러를 발생시켜야한다', () => { + expect(() => builder.from('')).toThrow('유효한 테이블 이름을 입력해주세요.'); + }); + }); + + describe('timeBetween() 메서드는', () => { + it('시간 범위 조건을 쿼리문에 추가해야한다', () => { + const start = new Date('2024-01-01'); + const end = new Date('2024-01-02'); + const result = builder.timeBetween(start, end).build(); + + expect(result.query).toContain('WHERE timestamp >= {startTime: DateTime64(3)}'); + expect(result.query).toContain('AND timestamp < {endTime: DateTime64(3)}'); + expect(result.params).toEqual({ + startTime: start, + endTime: end, + }); + }); + + it('시작 시간이 종료 시간보다 늦으면 에러를 발생시켜야한다', () => { + const start = new Date('2024-01-02'); + const end = new Date('2024-01-01'); + + expect(() => builder.timeBetween(start, end)).toThrow( + '시작 시간은 종료 시간보다 이전이어야 합니다.', + ); + }); + + it('유효하지 않은 날짜가 주어지면 에러를 발생시켜야한다', () => { + const invalidDate = new Date('invalid'); + const validDate = new Date('2024-01-01'); + + expect(() => builder.timeBetween(invalidDate, validDate)).toThrow( + '유효하지 않은 날짜 형식입니다.', + ); + }); + }); + + describe('filter() 메서드는', () => { + it('단일 필터 조건을 쿼리문에 추가해야한다', () => { + const result = builder.filter({ domain: 'example.com' }).build(); + + expect(result.query).toContain('WHERE domain = {domain:String}'); + expect(result.params).toEqual({ domain: 'example.com' }); + }); + + it('다중 필터 조건을 쿼리문에 추가해야한다', () => { + const result = builder + .filter({ + domain: 'example.com', + status: 200, + }) + .build(); + + expect(result.query).toContain('WHERE domain = {domain:String}'); + expect(result.query).toContain('AND status = {status:Int32}'); + expect(result.params).toEqual({ + domain: 'example.com', + status: 200, + }); + }); + + it('다양한 타입의 필터 값을 올바르게 처리해야한다', () => { + const date = new Date('2024-01-01'); + const result = builder + .filter({ + domain: 'example.com', + status: 200, + score: 95.5, + timestamp: date, + }) + .build(); + + expect(result.query).toContain('domain = {domain:String}'); + expect(result.query).toContain('status = {status:Int32}'); + expect(result.query).toContain('score = {score:Float64}'); + expect(result.query).toContain('timestamp = {timestamp:DateTime64(3)}'); + }); + + it('빈 필터 객체가 주어지면 기존 쿼리를 그대로 반환해야한다', () => { + const baseQuery = builder.from('metrics_table').build().query; + const result = builder.filter({}).build(); + expect(result.query).toBe(baseQuery); + }); + + it('필터 값이 null이면 기존 쿼리를 그대로 반환해야한다', () => { + const baseQuery = builder.from('metrics_table').build().query; + const result = builder.filter({} as Record).build(); + expect(result.query).toBe(baseQuery); + }); + + it('객체나 배열이 필터 값으로 주어지면 에러를 발생시켜야한다', () => { + expect(() => + builder.filter({ + test: { nested: 'value' }, + }), + ).toThrow(TimeSeriesQueryBuilderError); + + expect(() => + builder.filter({ + test: ['array', 'value'], + }), + ).toThrow(TimeSeriesQueryBuilderError); + }); + + it('undefined가 필터 값으로 주어지면 에러를 발생시켜야한다', () => { + expect(() => + builder.filter({ + test: undefined, + }), + ).toThrow(TimeSeriesQueryBuilderError); + }); + }); + + describe('groupBy() 메서드는', () => { + it('단일 컬럼으로 GROUP BY 절을 추가해야한다', () => { + const result = builder.groupBy(['host']).build(); + expect(result.query).toContain('GROUP BY host'); + }); + + it('다중 컬럼으로 GROUP BY 절을 추가해야한다', () => { + const result = builder.groupBy(['host', 'domain']).build(); + expect(result.query).toContain('GROUP BY host, domain'); + }); + + it('유효하지 않은 컬럼명이 포함되면 에러를 발생시켜야한다', () => { + expect(() => builder.groupBy(['', 'domain'])).toThrow( + '유효하지 않은 그룹화 컬럼이 포함되어 있습니다.', + ); + }); + + it('유효하지 않은 필드형식을 받으면 에러를 발생시켜야한다', () => { + const invalidField: string[] = ['']; + expect(() => builder.groupBy(invalidField)).toThrow( + '유효하지 않은 그룹화 컬럼이 포함되어 있습니다.', + ); + }); + }); + + describe('orderBy() 메서드는', () => { + it('오름차순 정렬 조건을 쿼리문에 추가해야한다', () => { + const result = builder.orderBy(['timestamp']).build(); + expect(result.query).toContain('ORDER BY timestamp'); + expect(result.query).not.toContain('DESC'); + }); + + it('내림차순 정렬 조건을 쿼리문에 추가해야한다', () => { + const result = builder.orderBy(['timestamp'], true).build(); + expect(result.query).toContain('ORDER BY timestamp DESC'); + }); + + it('다중 필드 정렬 조건을 쿼리문에 추가해야한다', () => { + const result = builder.orderBy(['domain', 'timestamp'], true).build(); + expect(result.query).toContain('ORDER BY domain, timestamp DESC'); + }); + + it('정렬 필드가 비어있으면 에러를 발생시켜야한다', () => { + expect(() => builder.orderBy([])).toThrow('정렬할 필드를 1개 이상 지정해주세요.'); + }); + + it('빈 배열이 주어지면 그룹화하지 않아야한다', () => { + const baseQuery = builder.from('metrics_table').build().query; + const result = builder.groupBy([]).build(); + expect(result.query).toBe(baseQuery); + }); + + it('null이나 undefined가 주어지면 그룹화하지 않아야한다', () => { + const baseQuery = builder.from('metrics_table').build().query; + + const resultWithNull = builder.groupBy(null as unknown as string[]).build(); + expect(resultWithNull.query).toBe(baseQuery); + + const resultWithUndefined = builder.groupBy(undefined as unknown as string[]).build(); + expect(resultWithUndefined.query).toBe(baseQuery); + }); + + it('유효하지 않은 필드형식을 받으면 에러를 발생시켜야한다', () => { + const invalidField: string[] = ['']; + expect(() => builder.orderBy(invalidField)).toThrow( + '유효하지 않은 필드명이 포함되어 있습니다.', + ); + }); + }); + + describe('limit() 메서드는', () => { + it('LIMIT 절을 쿼리문에 추가해야한다', () => { + const result = builder.limit(10).build(); + expect(result.query).toContain('LIMIT 10'); + }); + + it('음수가 주어지면 에러를 발생시켜야한다', () => { + expect(() => builder.limit(-1)).toThrow('limit 값은 0 이상이어야 합니다.'); + }); + + it('실수가 주어지면 에러를 발생시켜야한다', () => { + expect(() => builder.limit(10.5)).toThrow('limit 값은 정수여야 합니다.'); + }); + }); + + describe('build() 메서드는', () => { + it('모든 조건이 포함된 완성된 쿼리문을 생성해야한다', () => { + const start = new Date('2024-01-01'); + const end = new Date('2024-01-02'); + + const result = builder + .interval('1 MINUTE') + .metrics([{ name: 'cpu_usage', aggregation: 'avg' }]) + .from('metrics_table') + .timeBetween(start, end) + .filter({ domain: 'example.com' }) + .groupBy(['host']) + .orderBy(['timestamp'], true) + .limit(10) + .build(); + + expect(result.query).toContain('SELECT'); + expect(result.query).toContain('toStartOf1 MINUTE(timestamp) as timestamp'); + expect(result.query).toContain('avg(cpu_usage)'); + expect(result.query).toContain('FROM metrics_table'); + expect(result.query).toContain('WHERE timestamp >= {startTime: DateTime64(3)}'); + expect(result.query).toContain('AND timestamp < {endTime: DateTime64(3)}'); + expect(result.query).toContain('AND domain = {domain:String}'); + expect(result.query).toContain('GROUP BY host'); + expect(result.query).toContain('ORDER BY timestamp DESC'); + expect(result.query).toContain('LIMIT 10'); + + expect(result.params).toEqual({ + startTime: start, + endTime: end, + domain: 'example.com', + }); + }); + + it('개발 환경에서는 쿼리를 로깅해야한다', () => { + const originalEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + + builder.from('metrics_table').build(); + expect(consoleSpy).toHaveBeenCalled(); + + process.env.NODE_ENV = originalEnv; + }); + }); +}); diff --git a/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts b/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts index 61ddc637..c4e7f870 100644 --- a/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts +++ b/backend/console-server/src/clickhouse/query-builder/time-series.query-builder.ts @@ -1,12 +1,14 @@ -import type { MetricAggregationType } from '../util/metric-expressions'; +import type { MetricAggregationType, MetricFunction } from '../util/metric-expressions'; import { metricExpressions } from '../util/metric-expressions'; -import { mapFilterCondition } from '../util/map-filter-condition'; +import { TimeSeriesQueryBuilderError } from './time-series-query-builder.error'; -interface metric { +interface Metric { name: string; aggregation?: MetricAggregationType; } +type FilterValue = string | number | Date | unknown; + export class TimeSeriesQueryBuilder { private query: string; private params: Record = {}; @@ -26,89 +28,297 @@ export class TimeSeriesQueryBuilder { return this; } - metrics(metrics: metric[]): this { - const metricsQuery = metrics.map((metric) => { - if (metric.aggregation) { - const expression = metricExpressions[metric.aggregation]; + /** + * 쿼리에 메트릭을 추가하며, 선택적으로 집계 함수를 적용합니다. + * + * @param {Metric[]} metrics - 메트릭 이름과 선택적 집계 타입을 포함하는 메트릭 객체 배열 + * @returns {this} 메서드 체이닝을 위해 현재 인스턴스를 반환합니다 + * @throws {TimeSeriesQueryBuilderError} 지원하지 않는 집계 타입이 제공될 경우 에러를 발생시킵니다 + */ + metrics(metrics: Metric[]): this { + const metricsQuery = metrics.map((metric) => this.buildMetricExpression(metric)).join(', '); + + this.query += ` ${metricsQuery}`; + return this; + } - if (!expression) { - throw new Error(`Unsupported aggregation: ${metric.aggregation}`); - } - return `${expression(metric.name)}`; - } + private buildMetricExpression(metric: Metric): string { + if (!metric.aggregation) { return metric.name; - }); + } + const expression = this.getAggregationExpression(metric.aggregation); - this.query += ` ${metricsQuery.join(', ')}`; + return expression(metric.name); + } - return this; + private getAggregationExpression(aggregationType: MetricAggregationType): MetricFunction { + const expression = metricExpressions[aggregationType]; + + if (!expression) { + throw new TimeSeriesQueryBuilderError( + `지원하지 않는 집계 타입입니다: ${aggregationType}`, + ); + } + + return expression; } + /** + * 쿼리에 FROM 절을 추가하여 대상 테이블을 지정합니다. + * + * @param {string} table - 조회할 테이블 이름 + * @returns {this} 메서드 체이닝을 위해 현재 인스턴스를 반환합니다 + * @throws {Error} 테이블 이름이 비어있거나 유효하지 않은 경우 에러를 발생시킵니다 + * + * @example + * // 기본 테이블 지정 + * queryBuilder.from('users') + * + * // 스키마가 포함된 테이블 지정 + * queryBuilder.from('public.users') + */ from(table: string): this { - this.query += ` - FROM ${table}`; + this.validateTableName(table); + this.query += `\nFROM ${table}`; return this; } - timeBetween(start: Date, end: Date) { - this.query += ` - WHERE timestamp >= {startTime: DateTime64(3)} - AND timestamp < {endTime: DateTime64(3)}`; + private validateTableName(table: string): void { + if (!table || typeof table !== 'string' || !table.trim()) { + throw new Error('유효한 테이블 이름을 입력해주세요.'); + } + } - this.params.startTime = start; - this.params.endTime = end; + /** + * 특정 기간 동안의 데이터를 조회하기 위한 시간 범위 조건을 추가합니다. + * + * @param {Date} start - 조회 시작 시간 + * @param {Date} end - 조회 종료 시간 + * @returns {this} 메서드 체이닝을 위해 현재 인스턴스를 반환합니다 + * @throws {TimeSeriesQueryBuilderError} 시작 시간이 종료 시간보다 늦거나, 유효하지 않은 Date 객체가 전달될 경우 에러를 발생시킵니다 + * + * @example + * // 특정 기간 데이터 조회 + * const start = new Date('2024-01-01'); + * const end = new Date('2024-01-02'); + * queryBuilder.timeBetween(start, end); + */ + timeBetween(start: Date, end: Date): this { + this.validateTimeRange(start, end); + this.addTimeRangeSql(); + this.updateTimeParameters(start, end); return this; } + private validateTimeRange(start: Date, end: Date): void { + if (isNaN(start.getTime()) || isNaN(end.getTime())) { + throw new Error('유효하지 않은 날짜 형식입니다.'); + } + + if (start >= end) { + throw new Error('시작 시간은 종료 시간보다 이전이어야 합니다.'); + } + } + + private addTimeRangeSql(): void { + const timeRangeCondition = ` +WHERE timestamp >= {startTime: DateTime64(3)} +AND timestamp < {endTime: DateTime64(3)}`; + + this.query += timeRangeCondition; + } + + private updateTimeParameters(start: Date, end: Date): void { + this.params.startTime = start; + this.params.endTime = end; + } + + /** + * 쿼리에 필터 조건을 추가합니다. 각 필터는 key-value 쌍으로 제공되며, + * WHERE 절이나 AND 조건으로 자동 변환됩니다. + * + * @param {Record} filters - 필터 조건을 담은 객체 + * @returns {this} 메서드 체이닝을 위해 현재 인스턴스를 반환합니다 + * @throws {TimeSeriesQueryBuilderError} 지원하지 않는 필터 값 타입이 제공될 경우 에러를 발생시킵니다 + * + * @example + * // 단일 필터 조건 + * queryBuilder.filter({ domain: 'example.com' }) + * + * // 다중 필터 조건 + * queryBuilder.filter({ + * host: 'host_001', + * name: 'project001', + * }) + */ filter(filters: Record): this { - if (filters) { - const conditions = Object.entries(filters).map(([key, value]) => { - const { condition, param } = mapFilterCondition(key, value); - this.params[key] = param; - return condition; - }); - - if (this.query.includes('WHERE')) { - this.query += ` AND ${conditions.join(' AND ')}`; - } else { - this.query += ` WHERE ${conditions.join(' AND ')}`; - } + if (!filters || Object.keys(filters).length === 0) { + return this; } + const conditions = this.buildFilterConditions(filters); + this.appendFilterToQuery(conditions); + return this; } - groupBy(group: string[]): this { - if (group.length > 0) { - this.query += ` GROUP BY ${group.join(', ')}`; + private buildFilterConditions(filters: Record): string[] { + return Object.entries(filters).map(([key, value]) => { + const { condition, param } = this.createFilterCondition(key, value); + this.params[key] = param; + return condition; + }); + } + + private createFilterCondition( + key: string, + value: FilterValue, + ): { condition: string; param: FilterValue } { + const type = this.determineValueType(key, value); + return { condition: `${key} = {${key}:${type}}`, param: value }; + } + + private determineValueType(key: string, value: FilterValue): string { + if (typeof value === 'string') return 'String'; + if (typeof value === 'number') return this.getNumericType(value); + if (value instanceof Date) return 'DateTime64(3)'; + + throw new TimeSeriesQueryBuilderError( + `지원하지 않는 필터 값 타입입니다. key: "${key}", type: ${typeof value}`, + ); + } + + private getNumericType(value: number): string { + return Number.isInteger(value) ? 'Int32' : 'Float64'; + } + + private appendFilterToQuery(conditions: string[]): void { + const joinedConditions = conditions.join(' AND '); + const whereClause = this.query.includes('WHERE') + ? ` AND ${joinedConditions}` + : ` WHERE ${joinedConditions}`; + this.query += whereClause; + } + + /** + * 쿼리에 GROUP BY 절을 추가합니다. + * + * @param {string[]} groups - 그룹화할 컬럼명 배열 + * @returns {this} 메서드 체이닝을 위해 현재 인스턴스를 반환합니다 + * @throws {TimeSeriesQueryBuilderError} 빈 문자열이나 유효하지 않은 컬럼명이 포함된 경우 에러를 발생시킵니다 + * + * @example + * // 단일 컬럼 그룹화 + * queryBuilder.groupBy(['host']) + * + * // 다중 컬럼 그룹화 + * queryBuilder.groupBy(['host', 'name', 'domain']) + */ + groupBy(groups: string[]): this { + if (!this.isValidGroups(groups)) { + return this; } + this.validateGroupColumns(groups); + this.query += ` GROUP BY ${groups.join(', ')}`; + return this; } - orderBy(fields: string[], desc: boolean) { - this.query += ` ORDER BY ${fields.join(', ')}`; + private isValidGroups(groups: string[]): boolean { + return Array.isArray(groups) && groups.length > 0; + } - if (desc) { - this.query += ` DESC`; + private validateGroupColumns(groups: string[]): void { + const invalidColumns = groups.filter( + (column) => !column || typeof column !== 'string' || !column.trim(), + ); + + if (invalidColumns.length > 0) { + throw new TimeSeriesQueryBuilderError('유효하지 않은 그룹화 컬럼이 포함되어 있습니다.'); } + } + + /** + * 쿼리에 ORDER BY 절을 추가하여 결과를 정렬합니다. + * + * @param {string[]} fields - 정렬할 필드명 배열 + * @param {boolean} desc - 내림차순 정렬 여부 (true: 내림차순, false: 오름차순) + * @returns {this} 메서드 체이닝을 위해 현재 인스턴스를 반환합니다 + * @throws {TimeSeriesQueryBuilderError} 필드명이 비어있거나 유효하지 않은 경우 에러를 발생시킵니다 + * + * @example + * // 단일 필드 오름차순 정렬 + * queryBuilder.orderBy(['timestamp'], false) + * + * // 다중 필드 내림차순 정렬 + * queryBuilder.orderBy(['domain', 'timestamp'], true) + */ + orderBy(fields: string[], desc: boolean = false): this { + this.validateOrderFields(fields); + this.appendOrderByClause(fields, desc); return this; } + private validateOrderFields(fields: string[]): void { + if (!Array.isArray(fields) || fields.length === 0) { + throw new TimeSeriesQueryBuilderError('정렬할 필드를 1개 이상 지정해주세요.'); + } + + const invalidFields = fields.filter((field) => !field || !field.trim()); + if (invalidFields.length > 0) { + throw new TimeSeriesQueryBuilderError('유효하지 않은 필드명이 포함되어 있습니다.'); + } + } + + private appendOrderByClause(fields: string[], desc: boolean): void { + const orderClause = ` ORDER BY ${fields.join(', ')}`; + this.query += orderClause; + + if (desc) { + this.query += ' DESC'; + } + } + + /** + * 쿼리 결과의 최대 행 수를 제한합니다. + * + * @param {number} value - 제한할 행의 수 + * @returns {this} 메서드 체이닝을 위해 현재 인스턴스를 반환합니다 + * @throws {TimeSeriesQueryBuilderError} limit 값이 양의 정수가 아닐 경우 에러를 발생시킵니다 + * + * @example + * // 상위 10개 결과만 조회 + * queryBuilder.limit(10) + */ limit(value: number): this { + this.validateLimitValue(value); + this.limitValue = value; return this; } + private validateLimitValue(value: number): void { + if (value < 0) { + throw new TimeSeriesQueryBuilderError('limit 값은 0 이상이어야 합니다.'); + } + + if (!Number.isInteger(value)) { + throw new TimeSeriesQueryBuilderError('limit 값은 정수여야 합니다.'); + } + } + build() { if (this.limitValue) { this.query += ` LIMIT ${this.limitValue}`; } - console.log(this.query); + if (process.env.NODE_ENV === 'development') { + console.log(this.query); + } return { query: this.query, params: this.params }; } diff --git a/backend/console-server/src/clickhouse/util/clickhouse-client.error.ts b/backend/console-server/src/clickhouse/util/clickhouse-client.error.ts new file mode 100644 index 00000000..2634fe80 --- /dev/null +++ b/backend/console-server/src/clickhouse/util/clickhouse-client.error.ts @@ -0,0 +1,8 @@ +import { ClickhouseError } from '../core/clickhouse.error'; + +export class ClickhouseClientError extends ClickhouseError { + constructor(message: string, error?: Error) { + super(message, error); + super.name = 'ClickhouseClientError'; + } +} diff --git a/backend/console-server/src/clickhouse/util/map-filter-condition.ts b/backend/console-server/src/clickhouse/util/map-filter-condition.ts index 7ba3f109..e69de29b 100644 --- a/backend/console-server/src/clickhouse/util/map-filter-condition.ts +++ b/backend/console-server/src/clickhouse/util/map-filter-condition.ts @@ -1,19 +0,0 @@ -type FilterValue = string | number | Date | unknown; - -export function mapFilterCondition( - key: string, - value: FilterValue, -): { condition: string; param: FilterValue } { - let type: string; - - if (typeof value === 'string') { - type = 'String'; - } else if (typeof value === 'number') { - type = Number.isInteger(value) ? 'Int32' : 'Float64'; - } else if (value instanceof Date) { - type = 'DateTime64(3)'; - } else { - throw new Error(`Unsupported filter value type for key "${key}": ${typeof value}`); - } - return { condition: `${key} = {${key}:${type}}`, param: value }; -} diff --git a/backend/console-server/src/clickhouse/util/metric-expressions.ts b/backend/console-server/src/clickhouse/util/metric-expressions.ts index 0116aa5d..6c16b1dc 100644 --- a/backend/console-server/src/clickhouse/util/metric-expressions.ts +++ b/backend/console-server/src/clickhouse/util/metric-expressions.ts @@ -1,14 +1,12 @@ -type MetricFunction = (metric: string) => string; - export const metricExpressions: Record = { avg: (metric: string) => `avg(${metric}) as avg_${metric}`, count: () => `count() as count`, sum: (metric: string) => `sum(${metric}) as sum_${metric}`, min: (metric: string) => `min(${metric}) as min_${metric}`, max: (metric: string) => `max(${metric}) as max_${metric}`, - p95: (metric: string) => `quantile(0.95)(${metric}) as p95_${metric}`, - p99: (metric: string) => `quantile(0.99)(${metric}) as p99_${metric}`, rate: (metric: string) => `(sum(${metric}) / count(*)) * 100 as ${metric}_rate`, }; export type MetricAggregationType = keyof typeof metricExpressions; + +export type MetricFunction = (metric: string) => string; diff --git a/backend/console-server/src/log/log.repository.spec.ts b/backend/console-server/src/log/log.repository.spec.ts index a7ad563b..d803c841 100644 --- a/backend/console-server/src/log/log.repository.spec.ts +++ b/backend/console-server/src/log/log.repository.spec.ts @@ -76,7 +76,7 @@ describe('LogRepository 테스트', () => { expect(result).toEqual({ success_rate: 98.5 }); expect(clickhouse.query).toHaveBeenCalledWith( - 'SELECT (sum(is_error) / count(*)) * 100 as is_error_rate\n FROM http_log', + 'SELECT (sum(is_error) / count(*)) * 100 as is_error_rate\nFROM http_log', ); }); @@ -99,8 +99,8 @@ describe('LogRepository 테스트', () => { const result = await repository.findResponseSuccessRateByProject(domain); const expectedQuery = `SELECT (sum(is_error) / count(*)) * 100 as is_error_rate - FROM (SELECT is_error, timestamp - FROM http_log WHERE host = {host:String} ORDER BY timestamp DESC LIMIT 1000) as subquery`; +FROM (SELECT is_error, timestamp +FROM http_log WHERE host = {host:String} ORDER BY timestamp DESC LIMIT 1000) as subquery`; expect(result).toEqual({ success_rate: 98.5 }); expect(clickhouse.query).toHaveBeenCalledWith( diff --git a/backend/console-server/src/log/log.repository.ts b/backend/console-server/src/log/log.repository.ts index 648156dc..8bc77a6d 100644 --- a/backend/console-server/src/log/log.repository.ts +++ b/backend/console-server/src/log/log.repository.ts @@ -183,7 +183,6 @@ export class LogRepository { return result?.dau ? result.dau : 0; } - async findSpeedRank() { const { query, params } = new TimeSeriesQueryBuilder() .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'host' }]) @@ -195,7 +194,7 @@ export class LogRepository { const results = await this.clickhouse.query(query, params); return results.map((result) => plainToInstance(SpeedRankMetric, result)); } - + async findTrafficTop5Chart() { const now = new Date(); const today = new Date(now.setHours(0, 0, 0, 0)); diff --git a/backend/console-server/src/main.ts b/backend/console-server/src/main.ts index b803e01f..9ff67b97 100644 --- a/backend/console-server/src/main.ts +++ b/backend/console-server/src/main.ts @@ -4,7 +4,9 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { AppModule } from './app.module'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn'], + }); app.useGlobalPipes( new ValidationPipe({ From 57261e2b63ac576ccebdae4711df5ec9af8962e0 Mon Sep 17 00:00:00 2001 From: Envyw6567 Date: Sat, 23 Nov 2024 22:51:02 +0900 Subject: [PATCH 03/16] =?UTF-8?q?refactor:=20log=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 엔드포인트(자원)을 기준으로 3개의 서브 도메인으로 분리 - elapsed-time - traffic - success-rate - 테스트 케이스 리팩터링 완료 --- .../dto/get-avg-elapsed-time-response.dto.ts | 0 .../dto/get-avg-elapsed-time.dto.ts | 0 .../get-path-elapsed-time-response.dto.ts} | 16 +- .../dto/get-path-elapsed-time.rank.ts} | 2 +- .../dto/get-top5-elapsed-time.dto.ts} | 2 +- .../dto/get-top5-elapsed.time.ts} | 12 +- .../elapsed-time.controller.spec.ts | 150 +++++++ .../elapsed-time/elapsed-time.controller.ts | 59 +++ .../log/elapsed-time/elapsed-time.module.ts | 15 + .../elapsed-time.repository.spec.ts | 51 +++ .../elapsed-time/elapsed-time.repository.ts | 65 +++ .../elapsed-time/elapsed-time.service.spec.ts | 60 +++ .../log/elapsed-time/elapsed-time.service.ts | 82 ++++ .../metric/avg-elapsed-time.metric.ts | 0 .../metric/host-avg-elapsed-time.metric.ts} | 2 +- .../metric/path-elapsed-time.metric.ts} | 2 +- .../src/log/log.contorller.spec.ts | 393 ------------------ .../src/log/log.controller.spec.ts | 67 +++ .../console-server/src/log/log.controller.ts | 176 -------- backend/console-server/src/log/log.module.ts | 10 +- .../src/log/log.repository.spec.ts | 187 --------- .../console-server/src/log/log.repository.ts | 224 ---------- .../src/log/log.service.spec.ts | 356 +--------------- backend/console-server/src/log/log.service.ts | 206 --------- .../get-project-success-rate-response.dto.ts} | 2 +- .../dto/get-project-success-rate.dto.ts} | 2 +- .../dto/get-success-rate-response.dto.ts | 0 .../dto/get-success-rate.dto.ts | 0 .../metric/error-rate.metric.ts | 0 .../metric/success-rate.metric.ts | 0 .../success-rate.controller.spec.ts | 95 +++++ .../success-rate/success-rate.controller.ts | 42 ++ .../log/success-rate/success-rate.module.ts | 15 + .../success-rate.repository.spec.ts | 93 +++++ .../success-rate/success-rate.repository.ts | 58 +++ .../success-rate/success-rate.service.spec.ts | 142 +++++++ .../log/success-rate/success-rate.service.ts | 43 ++ .../get-traffic-by-generation-response.dto.ts | 0 .../dto/get-traffic-by-generation.dto.ts | 0 .../get-traffic-by-project-response.dto.ts | 0 .../dto/get-traffic-by-project.dto.ts | 0 ...t-traffic-daily-difference-response.dto.ts | 0 .../dto/get-traffic-daily-difference.dto.ts | 0 .../dto/get-traffic-rank-response.dto.ts | 0 .../get-traffic-top5-chart-response.dto.ts | 0 .../dto/get-traffic-top5-chart.dto.ts | 0 .../dto/get-traffic-top5.dto.ts} | 2 +- .../metric/traffic-chart.metric.ts} | 0 .../traffic-count-by-timeunit.metric.ts | 0 .../metric/traffic-count.metric.ts | 0 .../metric/traffic-rank-top5.metric.ts | 0 .../metric/traffic-rank.metric.ts | 0 .../log/traffic/traffic.controller.spec.ts | 181 ++++++++ .../src/log/traffic/traffic.controller.ts | 97 +++++ .../src/log/traffic/traffic.module.ts | 15 + .../log/traffic/traffic.repository.spec.ts | 147 +++++++ .../src/log/traffic/traffic.repository.ts | 126 ++++++ .../src/log/traffic/traffic.service.spec.ts | 285 +++++++++++++ .../src/log/traffic/traffic.service.ts | 131 ++++++ 59 files changed, 2051 insertions(+), 1562 deletions(-) rename backend/console-server/src/log/{ => elapsed-time}/dto/get-avg-elapsed-time-response.dto.ts (100%) rename backend/console-server/src/log/{ => elapsed-time}/dto/get-avg-elapsed-time.dto.ts (100%) rename backend/console-server/src/log/{dto/get-path-speed-rank-response.dto.ts => elapsed-time/dto/get-path-elapsed-time-response.dto.ts} (72%) rename backend/console-server/src/log/{dto/get-path-speed-rank.dto.ts => elapsed-time/dto/get-path-elapsed-time.rank.ts} (88%) rename backend/console-server/src/log/{dto/get-speed-rank.dto.ts => elapsed-time/dto/get-top5-elapsed-time.dto.ts} (89%) rename backend/console-server/src/log/{dto/get-speed-rank-response.dto.ts => elapsed-time/dto/get-top5-elapsed.time.ts} (65%) create mode 100644 backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts create mode 100644 backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts create mode 100644 backend/console-server/src/log/elapsed-time/elapsed-time.module.ts create mode 100644 backend/console-server/src/log/elapsed-time/elapsed-time.repository.spec.ts create mode 100644 backend/console-server/src/log/elapsed-time/elapsed-time.repository.ts create mode 100644 backend/console-server/src/log/elapsed-time/elapsed-time.service.spec.ts create mode 100644 backend/console-server/src/log/elapsed-time/elapsed-time.service.ts rename backend/console-server/src/log/{ => elapsed-time}/metric/avg-elapsed-time.metric.ts (100%) rename backend/console-server/src/log/{metric/speed-rank.metric.ts => elapsed-time/metric/host-avg-elapsed-time.metric.ts} (83%) rename backend/console-server/src/log/{metric/elapsed-time-by-path.metric.ts => elapsed-time/metric/path-elapsed-time.metric.ts} (76%) delete mode 100644 backend/console-server/src/log/log.contorller.spec.ts create mode 100644 backend/console-server/src/log/log.controller.spec.ts rename backend/console-server/src/log/{dto/get-success-rate-by-project-response.dto.ts => success-rate/dto/get-project-success-rate-response.dto.ts} (89%) rename backend/console-server/src/log/{dto/get-success-rate-by-project.dto.ts => success-rate/dto/get-project-success-rate.dto.ts} (86%) rename backend/console-server/src/log/{ => success-rate}/dto/get-success-rate-response.dto.ts (100%) rename backend/console-server/src/log/{ => success-rate}/dto/get-success-rate.dto.ts (100%) rename backend/console-server/src/log/{ => success-rate}/metric/error-rate.metric.ts (100%) rename backend/console-server/src/log/{ => success-rate}/metric/success-rate.metric.ts (100%) create mode 100644 backend/console-server/src/log/success-rate/success-rate.controller.spec.ts create mode 100644 backend/console-server/src/log/success-rate/success-rate.controller.ts create mode 100644 backend/console-server/src/log/success-rate/success-rate.module.ts create mode 100644 backend/console-server/src/log/success-rate/success-rate.repository.spec.ts create mode 100644 backend/console-server/src/log/success-rate/success-rate.repository.ts create mode 100644 backend/console-server/src/log/success-rate/success-rate.service.spec.ts create mode 100644 backend/console-server/src/log/success-rate/success-rate.service.ts rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-by-generation-response.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-by-generation.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-by-project-response.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-by-project.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-daily-difference-response.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-daily-difference.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-rank-response.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-top5-chart-response.dto.ts (100%) rename backend/console-server/src/log/{ => traffic}/dto/get-traffic-top5-chart.dto.ts (100%) rename backend/console-server/src/log/{dto/get-traffic-rank.dto.ts => traffic/dto/get-traffic-top5.dto.ts} (91%) rename backend/console-server/src/log/{metric/trafficChart.metric.ts => traffic/metric/traffic-chart.metric.ts} (100%) rename backend/console-server/src/log/{ => traffic}/metric/traffic-count-by-timeunit.metric.ts (100%) rename backend/console-server/src/log/{ => traffic}/metric/traffic-count.metric.ts (100%) rename backend/console-server/src/log/{ => traffic}/metric/traffic-rank-top5.metric.ts (100%) rename backend/console-server/src/log/{ => traffic}/metric/traffic-rank.metric.ts (100%) create mode 100644 backend/console-server/src/log/traffic/traffic.controller.spec.ts create mode 100644 backend/console-server/src/log/traffic/traffic.controller.ts create mode 100644 backend/console-server/src/log/traffic/traffic.module.ts create mode 100644 backend/console-server/src/log/traffic/traffic.repository.spec.ts create mode 100644 backend/console-server/src/log/traffic/traffic.repository.ts create mode 100644 backend/console-server/src/log/traffic/traffic.service.spec.ts create mode 100644 backend/console-server/src/log/traffic/traffic.service.ts diff --git a/backend/console-server/src/log/dto/get-avg-elapsed-time-response.dto.ts b/backend/console-server/src/log/elapsed-time/dto/get-avg-elapsed-time-response.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-avg-elapsed-time-response.dto.ts rename to backend/console-server/src/log/elapsed-time/dto/get-avg-elapsed-time-response.dto.ts diff --git a/backend/console-server/src/log/dto/get-avg-elapsed-time.dto.ts b/backend/console-server/src/log/elapsed-time/dto/get-avg-elapsed-time.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-avg-elapsed-time.dto.ts rename to backend/console-server/src/log/elapsed-time/dto/get-avg-elapsed-time.dto.ts diff --git a/backend/console-server/src/log/dto/get-path-speed-rank-response.dto.ts b/backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time-response.dto.ts similarity index 72% rename from backend/console-server/src/log/dto/get-path-speed-rank-response.dto.ts rename to backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time-response.dto.ts index d4ed9333..fffa8f89 100644 --- a/backend/console-server/src/log/dto/get-path-speed-rank-response.dto.ts +++ b/backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time-response.dto.ts @@ -1,7 +1,7 @@ import { Exclude, Expose, Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; -export class ResponseTimeByPath { +export class PathResponseTime { @ApiProperty({ example: '/api/v1/resource', description: '사용자의 요청 경로', @@ -18,7 +18,7 @@ export class ResponseTimeByPath { } @Exclude() -export class GetPathSpeedRankResponseDto { +export class GetPathElapsedTimeResponseDto { @ApiProperty({ example: 'watchducks', description: '프로젝트 이름', @@ -27,18 +27,18 @@ export class GetPathSpeedRankResponseDto { projectName: string; @ApiProperty({ - type: [ResponseTimeByPath], + type: [PathResponseTime], description: '프로젝트의 가장 빠른 응답 경로 배열', }) @Expose() - @Type(() => ResponseTimeByPath) - fastestPaths: ResponseTimeByPath[]; + @Type(() => PathResponseTime) + fastestPaths: PathResponseTime[]; @ApiProperty({ - type: [ResponseTimeByPath], + type: [PathResponseTime], description: '프로젝트의 가장 느린 응답 경로 배열', }) @Expose() - @Type(() => ResponseTimeByPath) - slowestPaths: ResponseTimeByPath[]; + @Type(() => PathResponseTime) + slowestPaths: PathResponseTime[]; } diff --git a/backend/console-server/src/log/dto/get-path-speed-rank.dto.ts b/backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time.rank.ts similarity index 88% rename from backend/console-server/src/log/dto/get-path-speed-rank.dto.ts rename to backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time.rank.ts index b0111226..1b2f4f3a 100644 --- a/backend/console-server/src/log/dto/get-path-speed-rank.dto.ts +++ b/backend/console-server/src/log/elapsed-time/dto/get-path-elapsed-time.rank.ts @@ -1,7 +1,7 @@ import { IsNotEmpty, IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -export class GetPathSpeedRankDto { +export class GetPathElapsedTimeRank { @IsNotEmpty() @IsString() @ApiProperty({ diff --git a/backend/console-server/src/log/dto/get-speed-rank.dto.ts b/backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed-time.dto.ts similarity index 89% rename from backend/console-server/src/log/dto/get-speed-rank.dto.ts rename to backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed-time.dto.ts index 411d1b56..dd3915b8 100644 --- a/backend/console-server/src/log/dto/get-speed-rank.dto.ts +++ b/backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed-time.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsNumber } from 'class-validator'; -export class GetSpeedRankDto { +export class GetTop5ElapsedTimeDto { @IsNumber() @Type(() => Number) @ApiProperty({ diff --git a/backend/console-server/src/log/dto/get-speed-rank-response.dto.ts b/backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed.time.ts similarity index 65% rename from backend/console-server/src/log/dto/get-speed-rank-response.dto.ts rename to backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed.time.ts index 16fcb46f..57996af8 100644 --- a/backend/console-server/src/log/dto/get-speed-rank-response.dto.ts +++ b/backend/console-server/src/log/elapsed-time/dto/get-top5-elapsed.time.ts @@ -1,7 +1,7 @@ -import { Exclude, Expose, Type } from 'class-transformer'; +import { Expose, Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; -export class ProjectSpeedData { +export class ProjectElapsedTime { @ApiProperty({ example: 'watchducks', description: '해당 프로젝트명', @@ -18,11 +18,11 @@ export class ProjectSpeedData { avgResponseTime: number; } -export class GetSpeedRankResponseDto { +export class GetTop5ElapsedTime { @ApiProperty({ - type: [ProjectSpeedData], + type: [ProjectElapsedTime], description: '프로젝트별 응답 속도 배열', }) - @Type(() => ProjectSpeedData) - projectSpeedRank: ProjectSpeedData[]; + @Type(() => ProjectElapsedTime) + projectSpeedRank: ProjectElapsedTime[]; } diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts new file mode 100644 index 00000000..9731e8df --- /dev/null +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts @@ -0,0 +1,150 @@ +import { ElapsedTimeController } from './elapsed-time.controller'; +import { ElapsedTimeService } from './elapsed-time.service'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { HttpStatus } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; + +describe('ElapsedTimeController 테스트', () => { + let controller: ElapsedTimeController; + let service: ElapsedTimeService; + + const mockElapsedTimeService = { + getAvgElapsedTime: jest.fn(), + getPathElapsedTimeRank: jest.fn(), + getTop5ElapsedTime: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ElapsedTimeController], + providers: [ + { + provide: ElapsedTimeService, + useValue: mockElapsedTimeService, + }, + ], + }).compile(); + + controller = module.get(ElapsedTimeController); + service = module.get(ElapsedTimeService); + + jest.clearAllMocks(); + }); + + it('컨트롤러가 정의되어 있어야 한다', () => { + expect(controller).toBeDefined(); + }); + + describe('elapsedTime()은 ', () => { + const mockResult = { + status: HttpStatus.OK, + data: { avg_elapsed_time: 150 }, + }; + + it('평균 응답 시간을 ProjectResponseDto 형식으로 반환해야 한다', async () => { + mockElapsedTimeService.getAvgElapsedTime.mockResolvedValue(mockResult); + + const result = await controller.getElapsedTime( + plainToInstance(GetAvgElapsedTimeDto, { generation: 1 }), + ); + + expect(result).toEqual(mockResult); + expect(result).toHaveProperty('status', HttpStatus.OK); + expect(result).toHaveProperty('data.avg_elapsed_time'); + expect(service.getAvgElapsedTime).toHaveBeenCalledTimes(1); + }); + }); + + describe('getPathElapsedTimeRank()는 ', () => { + const mockRequestDto = { + projectName: 'example-project', + }; + + const mockResponseDto = { + projectName: 'example-project', + fastestPaths: [ + { path: '/api/v1/resource', avg_elapsed_time: 123.45 }, + { path: '/api/v1/users', avg_elapsed_time: 145.67 }, + { path: '/api/v1/orders', avg_elapsed_time: 150.89 }, + ], + slowestPaths: [ + { path: '/api/v1/reports', avg_elapsed_time: 345.67 }, + { path: '/api/v1/logs', avg_elapsed_time: 400.23 }, + { path: '/api/v1/stats', avg_elapsed_time: 450.56 }, + ], + }; + + it('프로젝트의 경로별 응답 속도 순위를 반환해야 한다', async () => { + mockElapsedTimeService.getPathElapsedTimeRank.mockResolvedValue(mockResponseDto); + + const result = await controller.getPathElapsedTimeRank(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(service.getPathElapsedTimeRank).toHaveBeenCalledWith(mockRequestDto); + expect(service.getPathElapsedTimeRank).toHaveBeenCalledTimes(1); + + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result.fastestPaths).toHaveLength(3); + expect(result.slowestPaths).toHaveLength(3); + }); + + it('서비스 에러 시 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockElapsedTimeService.getPathElapsedTimeRank.mockRejectedValue(error); + + await expect(controller.getPathElapsedTimeRank(mockRequestDto)).rejects.toThrow(error); + + expect(service.getPathElapsedTimeRank).toHaveBeenCalledWith(mockRequestDto); + expect(service.getPathElapsedTimeRank).toHaveBeenCalledTimes(1); + }); + + describe('getTop5ElapsedTime()는', () => { + const mockRequestDto = { + generation: 5, + }; + + const mockResponseDto = [ + { projectName: 'project1', avgElapsedTime: 123.45 }, + { projectName: 'project2', avgElapsedTime: 145.67 }, + { projectName: 'project3', avgElapsedTime: 150.89 }, + { projectName: 'project4', avgElapsedTime: 180.23 }, + { projectName: 'project5', avgElapsedTime: 200.34 }, + ]; + + it('응답 속도 TOP5 데이터를 반환해야 한다', async () => { + mockElapsedTimeService.getTop5ElapsedTime.mockResolvedValue(mockResponseDto); + + const result = await controller.getTop5ElapsedTime(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(result).toHaveLength(5); + expect(result[0]).toHaveProperty('projectName', 'project1'); + expect(result[0]).toHaveProperty('avgElapsedTime', 123.45); + expect(service.getTop5ElapsedTime).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTop5ElapsedTime).toHaveBeenCalledTimes(1); + }); + + it('서비스 메소드 호출시 에러가 발생하면 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockElapsedTimeService.getTop5ElapsedTime.mockRejectedValue(error); + + await expect(controller.getTop5ElapsedTime(mockRequestDto)).rejects.toThrow(error); + + expect(service.getTop5ElapsedTime).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTop5ElapsedTime).toHaveBeenCalledTimes(1); + }); + + it('데이터가 없을 때 빈 배열을 반환한다', async () => { + mockElapsedTimeService.getTop5ElapsedTime.mockResolvedValue([]); + + const result = await controller.getTop5ElapsedTime(mockRequestDto); + + expect(result).toEqual([]); + expect(service.getTop5ElapsedTime).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTop5ElapsedTime).toHaveBeenCalledTimes(1); + }); + }); + }); +}); diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts new file mode 100644 index 00000000..5a2a330f --- /dev/null +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts @@ -0,0 +1,59 @@ +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { GetAvgElapsedTimeResponseDto } from './dto/get-avg-elapsed-time-response.dto'; +import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; +import { ElapsedTimeService } from './elapsed-time.service'; +import { GetTop5ElapsedTime } from './dto/get-top5-elapsed.time'; +import { GetTop5ElapsedTimeDto } from './dto/get-top5-elapsed-time.dto'; +import { GetPathElapsedTimeResponseDto } from './dto/get-path-elapsed-time-response.dto'; +import { GetPathElapsedTimeRank } from './dto/get-path-elapsed-time.rank'; + +@Controller('elapsed-time') +export class ElapsedTimeController { + constructor(private readonly elapsedTimeService: ElapsedTimeService) {} + + @Get('') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '기수 총 트래픽 평균 응답시간 API', + description: '요청받은 기수의 트래픽에 대한 평균 응답시간을 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '평균 응답시간이 성공적으로 반환됨.', + type: GetAvgElapsedTimeResponseDto, + }) + async getElapsedTime(@Query() getAvgElapsedTimeDto: GetAvgElapsedTimeDto) { + return await this.elapsedTimeService.getAvgElapsedTime(getAvgElapsedTimeDto); + } + + @Get('/top5') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '기수 내 응답 속도 TOP5', + description: '요청받은 기수의 응답 속도 랭킹 TOP 5를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '응답 속도 랭킹 TOP5가 정상적으로 반환됨.', + type: GetTop5ElapsedTime, + }) + async getTop5ElapsedTime(@Query() getSpeedRankDto: GetTop5ElapsedTimeDto) { + return await this.elapsedTimeService.getTop5ElapsedTime(getSpeedRankDto); + } + + @Get('/path-rank') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '개별 프로젝트의 경로별 응답 속도 순위', + description: '개별 프로젝트의 경로별 응답 속도 중 가장 빠른/느린 3개를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '개별 프로젝트의 경로별 응답 속도 중 가장 빠른/느린 3개가 반환됨.', + type: GetPathElapsedTimeResponseDto, + }) + async getPathElapsedTimeRank(@Query() getPathElapsedTimeRank: GetPathElapsedTimeRank) { + return await this.elapsedTimeService.getPathElapsedTimeRank(getPathElapsedTimeRank); + } +} diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.module.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.module.ts new file mode 100644 index 00000000..6d89c0ce --- /dev/null +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.module.ts @@ -0,0 +1,15 @@ +import { ElapsedTimeController } from './elapsed-time.controller'; +import { ElapsedTimeService } from './elapsed-time.service'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import { ElapsedTimeRepository } from './elapsed-time.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], + controllers: [ElapsedTimeController], + providers: [ElapsedTimeService, ElapsedTimeRepository, Clickhouse], +}) +export class ElapsedTimeModule {} diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.repository.spec.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.repository.spec.ts new file mode 100644 index 00000000..8a167787 --- /dev/null +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.repository.spec.ts @@ -0,0 +1,51 @@ +import { ElapsedTimeRepository } from './elapsed-time.repository'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +describe('ElapsedTimeRepository 테스트', () => { + let repository: ElapsedTimeRepository; + let clickhouse: Clickhouse; + + const mockClickhouse = { + query: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ElapsedTimeRepository, + { + provide: Clickhouse, + useValue: mockClickhouse, + }, + ], + }).compile(); + + repository = module.get(ElapsedTimeRepository); + clickhouse = module.get(Clickhouse); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('레퍼지토리가 정의될 수 있어야 한다.', () => { + expect(repository).toBeDefined(); + }); + + describe('findAvgElapsedTime()는 ', () => { + it('TimeSeriesQueryBuilder를 사용하여 올바른 쿼리를 생성해야 한다.', async () => { + const mockResult = [{ avg_elapsed_time: 150 }]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.findAvgElapsedTime(); + + expect(result).toEqual(mockResult[0]); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching(/SELECT.*avg\(elapsed_time\)/), + expect.any(Object), + ); + }); + }); +}); diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.repository.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.repository.ts new file mode 100644 index 00000000..35686093 --- /dev/null +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.repository.ts @@ -0,0 +1,65 @@ +import { TimeSeriesQueryBuilder } from '../../clickhouse/query-builder/time-series.query-builder'; +import { AvgElapsedTimeMetric } from './metric/avg-elapsed-time.metric'; +import { plainToInstance } from 'class-transformer'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import { HostAvgElapsedTimeMetric } from './metric/host-avg-elapsed-time.metric'; +import { PathElapsedTimeMetric } from './metric/path-elapsed-time.metric'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ElapsedTimeRepository { + constructor(private readonly clickhouse: Clickhouse) {} + + async findAvgElapsedTime() { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([{ name: 'elapsed_time', aggregation: 'avg' }]) + .from('http_log') + .build(); + + const [result] = await this.clickhouse.query(query, params); + + return plainToInstance(AvgElapsedTimeMetric, result); + } + + async findAvgElapsedTimeLimit() { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'host' }]) + .from('http_log') + .groupBy(['host']) + .orderBy(['avg_elapsed_time'], false) + .limit(5) + .build(); + const results = await this.clickhouse.query(query, params); + return results.map((result) => plainToInstance(HostAvgElapsedTimeMetric, result)); + } + + async getFastestPathsByDomain(domain: string) { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'path' }]) + .from('http_log') + .filter({ host: domain }) + .groupBy(['path']) + .orderBy(['avg_elapsed_time'], false) + .limit(3) + .build(); + + const results = await this.clickhouse.query(query, params); + + return results.map((result) => plainToInstance(PathElapsedTimeMetric, result)); + } + + async findSlowestPathsByDomain(domain: string) { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'path' }]) + .from('http_log') + .filter({ host: domain }) + .groupBy(['path']) + .orderBy(['avg_elapsed_time'], true) + .limit(3) + .build(); + + const results = await this.clickhouse.query(query, params); + + return results.map((result) => plainToInstance(PathElapsedTimeMetric, result)); + } +} diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.service.spec.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.service.spec.ts new file mode 100644 index 00000000..e43b79d1 --- /dev/null +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.service.spec.ts @@ -0,0 +1,60 @@ +import { ElapsedTimeService } from './elapsed-time.service'; +import { ElapsedTimeRepository } from './elapsed-time.repository'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { plainToInstance } from 'class-transformer'; +import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; + +describe('ElapsedTimeService 테스트', () => { + let service: ElapsedTimeService; + let repository: ElapsedTimeRepository; + + const mockElapsedTimeRepository = { + findAvgElapsedTime: jest.fn(), + findPathSpeedRankByProject: jest.fn(), + findSpeedRank: jest.fn(), + getFastestPathsByDomain: jest.fn(), + findSlowestPathsByDomain: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ElapsedTimeService, + { + provide: getRepositoryToken(Project), + useValue: { findOne: jest.fn() }, + }, + { + provide: ElapsedTimeRepository, + useValue: mockElapsedTimeRepository, + }, + ], + }).compile(); + + service = module.get(ElapsedTimeService); + repository = module.get(ElapsedTimeRepository); + + jest.clearAllMocks(); + }); + + it('서비스가 정의될 수 있어야 한다.', () => { + expect(service).toBeDefined(); + }); + + describe('elapsedTime()는 ', () => { + it('평균 응답 시간을 반환할 수 있어야 한다.', async () => { + const mockTime = { avg_elapsed_time: 150 }; + mockElapsedTimeRepository.findAvgElapsedTime.mockResolvedValue(mockTime); + + const result = await service.getAvgElapsedTime( + plainToInstance(GetAvgElapsedTimeDto, { generation: 9 }), + ); + + expect(result).toEqual(mockTime); + expect(repository.findAvgElapsedTime).toHaveBeenCalled(); + }); + }); +}); diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.service.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.service.ts new file mode 100644 index 00000000..a4bbd34f --- /dev/null +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.service.ts @@ -0,0 +1,82 @@ +import type { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; +import { plainToInstance } from 'class-transformer'; +import { GetAvgElapsedTimeResponseDto } from './dto/get-avg-elapsed-time-response.dto'; +import { ElapsedTimeRepository } from './elapsed-time.repository'; +import type { GetTop5ElapsedTimeDto } from './dto/get-top5-elapsed-time.dto'; +import { GetTop5ElapsedTime, ProjectElapsedTime } from './dto/get-top5-elapsed.time'; +import type { Repository } from 'typeorm'; +import type { GetPathElapsedTimeRank } from './dto/get-path-elapsed-time.rank'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + GetPathElapsedTimeResponseDto, + PathResponseTime, +} from './dto/get-path-elapsed-time-response.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; + +@Injectable() +export class ElapsedTimeService { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + private readonly elapsedTimeRepository: ElapsedTimeRepository, + ) {} + + async getAvgElapsedTime(_getAvgElapsedTime: GetAvgElapsedTimeDto) { + const result = await this.elapsedTimeRepository.findAvgElapsedTime(); + + return plainToInstance(GetAvgElapsedTimeResponseDto, result); + } + + async getTop5ElapsedTime(_getTop5ElapsedTimeDto: GetTop5ElapsedTimeDto) { + const speedRankData = await this.elapsedTimeRepository.findAvgElapsedTimeLimit(); + const response = await Promise.all( + speedRankData.map(async (data) => { + const project = await this.projectRepository.findOne({ + where: { domain: data.host }, + select: ['name'], + }); + + return plainToInstance(ProjectElapsedTime, { + projectName: project?.name || 'Unknown', + avgResponseTime: data.avg_elapsed_time, + }); + }), + ); + + return plainToInstance(GetTop5ElapsedTime, response); + } + + async getPathElapsedTimeRank(getPathElapsedTimeRank: GetPathElapsedTimeRank) { + const { projectName } = getPathElapsedTimeRank; + const project = await this.projectRepository.findOne({ + where: { name: projectName }, + select: ['domain'], + }); + + if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); + + const fastestPaths = await this.elapsedTimeRepository.getFastestPathsByDomain( + project.domain, + ); + const slowestPaths = await this.elapsedTimeRepository.findSlowestPathsByDomain( + project.domain, + ); + + return plainToInstance(GetPathElapsedTimeResponseDto, { + projectName, + fastestPaths: fastestPaths.map((fastestPath) => + plainToInstance(PathResponseTime, { + path: fastestPath.path, + avgResponseTime: fastestPath.avg_elapsed_time, + }), + ), + slowestPaths: slowestPaths.map((slowestPath) => + plainToInstance(PathResponseTime, { + path: slowestPath.path, + avgResponseTime: slowestPath.avg_elapsed_time, + }), + ), + }); + } +} diff --git a/backend/console-server/src/log/metric/avg-elapsed-time.metric.ts b/backend/console-server/src/log/elapsed-time/metric/avg-elapsed-time.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/avg-elapsed-time.metric.ts rename to backend/console-server/src/log/elapsed-time/metric/avg-elapsed-time.metric.ts diff --git a/backend/console-server/src/log/metric/speed-rank.metric.ts b/backend/console-server/src/log/elapsed-time/metric/host-avg-elapsed-time.metric.ts similarity index 83% rename from backend/console-server/src/log/metric/speed-rank.metric.ts rename to backend/console-server/src/log/elapsed-time/metric/host-avg-elapsed-time.metric.ts index 057ededc..abb5b46a 100644 --- a/backend/console-server/src/log/metric/speed-rank.metric.ts +++ b/backend/console-server/src/log/elapsed-time/metric/host-avg-elapsed-time.metric.ts @@ -1,7 +1,7 @@ import { IsNumber, IsString } from 'class-validator'; import { Type } from 'class-transformer'; -export class SpeedRankMetric { +export class HostAvgElapsedTimeMetric { @IsString() host: string; diff --git a/backend/console-server/src/log/metric/elapsed-time-by-path.metric.ts b/backend/console-server/src/log/elapsed-time/metric/path-elapsed-time.metric.ts similarity index 76% rename from backend/console-server/src/log/metric/elapsed-time-by-path.metric.ts rename to backend/console-server/src/log/elapsed-time/metric/path-elapsed-time.metric.ts index 8ad50022..f4efaa28 100644 --- a/backend/console-server/src/log/metric/elapsed-time-by-path.metric.ts +++ b/backend/console-server/src/log/elapsed-time/metric/path-elapsed-time.metric.ts @@ -1,6 +1,6 @@ import { IsString } from 'class-validator'; -export class ElapsedTimeByPathMetric { +export class PathElapsedTimeMetric { @IsString() avg_elapsed_time: string; diff --git a/backend/console-server/src/log/log.contorller.spec.ts b/backend/console-server/src/log/log.contorller.spec.ts deleted file mode 100644 index 982ae27d..00000000 --- a/backend/console-server/src/log/log.contorller.spec.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { HttpStatus } from '@nestjs/common'; -import type { TestingModule } from '@nestjs/testing'; -import { LogController } from './log.controller'; -import { LogService } from './log.service'; -import { GetTrafficRankDto } from './dto/get-traffic-rank.dto'; -import { plainToInstance } from 'class-transformer'; -import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; - -interface TrafficRankResponseType { - status: number; - data: Array<{ host: string; count: number }>; -} - -describe('LogController 테스트', () => { - let controller: LogController; - let service: LogService; - - const mockLogService = { - getAvgElapsedTime: jest.fn(), - getTrafficRank: jest.fn(), - getResponseSuccessRate: jest.fn(), - getResponseSuccessRateByProject: jest.fn(), - getTrafficByGeneration: jest.fn(), - getPathSpeedRankByProject: jest.fn(), - getTrafficByProject: jest.fn(), - getTrafficDailyDifferenceByGeneration: jest.fn(), - getDAUByProject: jest.fn(), - getSpeedRank: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [LogController], - providers: [ - { - provide: LogService, - useValue: mockLogService, - }, - ], - }).compile(); - - controller = module.get(LogController); - service = module.get(LogService); - - jest.clearAllMocks(); - }); - - it('컨트롤러가 정의되어 있어야 한다', () => { - expect(controller).toBeDefined(); - }); - - describe('elapsedTime()은 ', () => { - const mockResult = { - status: HttpStatus.OK, - data: { avg_elapsed_time: 150 }, - }; - - it('평균 응답 시간을 ProjectResponseDto 형식으로 반환해야 한다', async () => { - mockLogService.getAvgElapsedTime.mockResolvedValue(mockResult); - - const result = await controller.getElapsedTime( - plainToInstance(GetAvgElapsedTimeDto, { generation: 1 }), - ); - - expect(result).toEqual(mockResult); - expect(result).toHaveProperty('status', HttpStatus.OK); - expect(result).toHaveProperty('data.avg_elapsed_time'); - expect(service.getAvgElapsedTime).toHaveBeenCalledTimes(1); - }); - }); - - describe('trafficRank()는 ', () => { - const mockResult: TrafficRankResponseType = { - status: HttpStatus.OK, - data: [ - { host: 'api1.example.com', count: 1000 }, - { host: 'api2.example.com', count: 800 }, - { host: 'api3.example.com', count: 600 }, - { host: 'api4.example.com', count: 400 }, - { host: 'api5.example.com', count: 200 }, - ], - }; - - it('TOP 5 트래픽 순위를 ProjectResponseDto 형식으로 반환해야 한다', async () => { - mockLogService.getTrafficRank.mockResolvedValue(mockResult); - - const result = (await controller.getTrafficRank( - plainToInstance(GetTrafficRankDto, { generation: 1 }), - )) as unknown as TrafficRankResponseType; - - expect(result).toEqual(mockResult); - expect(result).toHaveProperty('status', HttpStatus.OK); - expect(result.data).toHaveLength(5); - expect(service.getTrafficRank).toHaveBeenCalledTimes(1); - - const sortedData = [...result.data].sort((a, b) => b.count - a.count); - expect(result.data).toEqual(sortedData); - }); - }); - - describe('getResponseSuccessRate()는 ', () => { - const mockSuccessRateDto = { generation: 5 }; - const mockServiceResponse = { success_rate: 98.5 }; - - it('응답 성공률을 반환해야 한다', async () => { - mockLogService.getResponseSuccessRate.mockResolvedValue(mockServiceResponse); - - const result = await controller.getResponseSuccessRate(mockSuccessRateDto); - - expect(result).toEqual({ success_rate: 98.5 }); - expect(service.getResponseSuccessRate).toHaveBeenCalledWith(mockSuccessRateDto); - expect(service.getResponseSuccessRate).toHaveBeenCalledTimes(1); - }); - }); - - describe('getResponseSuccessRateByProject()는 ', () => { - const mockRequestDto = { projectName: 'example-project' }; - const mockServiceResponse = { - projectName: 'example-project', - success_rate: 95.5, - }; - - it('해당 프로젝트의 응답 성공률을 반환해야 한다', async () => { - mockLogService.getResponseSuccessRateByProject.mockResolvedValue(mockServiceResponse); - - const result = await controller.getResponseSuccessRateByProject(mockRequestDto); - - expect(result).toEqual(mockServiceResponse); - expect(result).toHaveProperty('projectName', 'example-project'); - expect(result).toHaveProperty('success_rate', 95.5); - expect(service.getResponseSuccessRateByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getResponseSuccessRateByProject).toHaveBeenCalledTimes(1); - }); - - it('서비스 호출 시, 에러가 발생하면 예외를 throw 해야 한다', async () => { - const error = new Error('Database error'); - mockLogService.getResponseSuccessRateByProject.mockRejectedValue(error); - - await expect( - controller.getResponseSuccessRateByProject(mockRequestDto), - ).rejects.toThrow(error); - - expect(service.getResponseSuccessRateByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getResponseSuccessRateByProject).toHaveBeenCalledTimes(1); - }); - - it('성공률이 100%인 경우에도 정상적으로 반환해야 한다', async () => { - const perfectRateResponse = { - projectName: 'example-project', - success_rate: 100, - }; - mockLogService.getResponseSuccessRateByProject.mockResolvedValue(perfectRateResponse); - - const result = await controller.getResponseSuccessRateByProject(mockRequestDto); - - expect(result).toEqual(perfectRateResponse); - expect(result.success_rate).toBe(100); - expect(service.getResponseSuccessRateByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getResponseSuccessRateByProject).toHaveBeenCalledTimes(1); - }); - }); - - describe('trafficByGeneration()는 ', () => { - it('기수별 트래픽 총량을 올바르게 반환해야 한다', async () => { - const mockTrafficByGenerationDto = { generation: 9 }; - const mockResponse = { count: 1000 }; - - mockLogService.getTrafficByGeneration.mockResolvedValue(mockResponse); - - const result = await controller.getTrafficByGeneration(mockTrafficByGenerationDto); - - expect(result).toEqual(mockResponse); - expect(result).toHaveProperty('count', 1000); - expect(service.getTrafficByGeneration).toHaveBeenCalledWith(mockTrafficByGenerationDto); - expect(service.getTrafficByGeneration).toHaveBeenCalledTimes(1); - }); - }); - - describe('getPathSpeedRankByProject()는 ', () => { - const mockRequestDto = { - projectName: 'example-project', - }; - - const mockResponseDto = { - projectName: 'example-project', - fastestPaths: [ - { path: '/api/v1/resource', avg_elapsed_time: 123.45 }, - { path: '/api/v1/users', avg_elapsed_time: 145.67 }, - { path: '/api/v1/orders', avg_elapsed_time: 150.89 }, - ], - slowestPaths: [ - { path: '/api/v1/reports', avg_elapsed_time: 345.67 }, - { path: '/api/v1/logs', avg_elapsed_time: 400.23 }, - { path: '/api/v1/stats', avg_elapsed_time: 450.56 }, - ], - }; - - it('프로젝트의 경로별 응답 속도 순위를 반환해야 한다', async () => { - mockLogService.getPathSpeedRankByProject.mockResolvedValue(mockResponseDto); - - const result = await controller.getPathSpeedRankByProject(mockRequestDto); - - expect(result).toEqual(mockResponseDto); - expect(service.getPathSpeedRankByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getPathSpeedRankByProject).toHaveBeenCalledTimes(1); - - expect(result).toHaveProperty('projectName', mockRequestDto.projectName); - expect(result.fastestPaths).toHaveLength(3); - expect(result.slowestPaths).toHaveLength(3); - }); - - it('서비스 에러 시 예외를 throw 해야 한다', async () => { - const error = new Error('Database error'); - mockLogService.getPathSpeedRankByProject.mockRejectedValue(error); - - await expect(controller.getPathSpeedRankByProject(mockRequestDto)).rejects.toThrow( - error, - ); - - expect(service.getPathSpeedRankByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getPathSpeedRankByProject).toHaveBeenCalledTimes(1); - }); - }); - - describe('getTrafficByProject()는', () => { - const mockRequestDto = { projectName: 'example-project', timeUnit: 'month' }; - const mockResponseDto = { - projectName: 'example-project', - timeUnit: 'month', - trafficData: [ - { timestamp: '2024-11-01', count: 14 }, - { timestamp: '2024-10-01', count: 10 }, - ], - }; - it('프로젝트의 시간 단위별 트래픽 데이터를 반환해야 한다', async () => { - mockLogService.getTrafficByProject.mockResolvedValue(mockResponseDto); - - const result = await controller.getTrafficByProject(mockRequestDto); - - expect(result).toEqual(mockResponseDto); - expect(result).toHaveProperty('projectName', mockRequestDto.projectName); - expect(result).toHaveProperty('timeUnit', mockRequestDto.timeUnit); - expect(result.trafficData).toHaveLength(2); - expect(result.trafficData[0]).toHaveProperty('timestamp', '2024-11-01'); - expect(result.trafficData[0]).toHaveProperty('count', 14); - expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); - }); - - it('서비스 에러 시 예외를 throw 해야 한다', async () => { - const error = new Error('Database error'); - mockLogService.getTrafficByProject.mockRejectedValue(error); - - await expect(controller.getTrafficByProject(mockRequestDto)).rejects.toThrow(error); - - expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); - }); - - it('빈 트래픽 데이터를 반환해야 한다 (No Data)', async () => { - const emptyResponseDto = { - projectName: 'example-project', - timeUnit: 'month', - trafficData: [], - }; - mockLogService.getTrafficByProject.mockResolvedValue(emptyResponseDto); - - const result = await controller.getTrafficByProject(mockRequestDto); - - expect(result).toEqual(emptyResponseDto); - expect(result).toHaveProperty('projectName', mockRequestDto.projectName); - expect(result).toHaveProperty('timeUnit', mockRequestDto.timeUnit); - expect(result.trafficData).toHaveLength(0); - expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); - }); - }); - - describe('getTrafficDailyDifferenceByGeneration()는 ', () => { - const mockRequestDto = { generation: 9 }; - const mockResponseDto = { - traffic_daily_difference: '+9100', - }; - - it('전일 대비 트래픽 차이를 리턴해야 한다', async () => { - mockLogService.getTrafficDailyDifferenceByGeneration.mockResolvedValue(mockResponseDto); - - const result = await controller.getTrafficDailyDifferenceByGeneration(mockRequestDto); - - expect(result).toEqual(mockResponseDto); - expect(result).toHaveProperty('traffic_daily_difference'); - expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledWith( - mockRequestDto, - ); - expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledTimes(1); - }); - - it('에러 발생 시, 예외를 throw 해야 한다', async () => { - const error = new Error('Database error'); - mockLogService.getTrafficDailyDifferenceByGeneration.mockRejectedValue(error); - - await expect( - controller.getTrafficDailyDifferenceByGeneration(mockRequestDto), - ).rejects.toThrow(error); - - expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledWith( - mockRequestDto, - ); - expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledTimes(1); - }); - }); - - describe('getDAUByProject()는', () => { - const mockRequestDto = { projectName: 'example-project', date: '2024-11-01' }; - - const mockResponseDto = { - projectName: 'example-project', - date: '2024-11-01', - dau: 125, - }; - - it('프로젝트명과 날짜가 들어왔을 때 DAU 데이터를 반환해야 한다', async () => { - mockLogService.getDAUByProject.mockResolvedValue(mockResponseDto); - - const result = await controller.getDAUByProject(mockRequestDto); - - expect(result).toEqual(mockResponseDto); - expect(result).toHaveProperty('projectName', mockRequestDto.projectName); - expect(result).toHaveProperty('date', mockRequestDto.date); - expect(result).toHaveProperty('dau', 125); - expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getDAUByProject).toHaveBeenCalledTimes(1); - }); - - it('서비스 에러 시 예외를 throw 해야 한다', async () => { - const error = new Error('Database error'); - mockLogService.getDAUByProject.mockRejectedValue(error); - - await expect(controller.getDAUByProject(mockRequestDto)).rejects.toThrow(error); - - expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getDAUByProject).toHaveBeenCalledTimes(1); - }); - }); - - describe('getSpeedRank()는', () => { - const mockRequestDto = { - generation: 5, - }; - - const mockResponseDto = [ - { projectName: 'project1', avgElapsedTime: 123.45 }, - { projectName: 'project2', avgElapsedTime: 145.67 }, - { projectName: 'project3', avgElapsedTime: 150.89 }, - { projectName: 'project4', avgElapsedTime: 180.23 }, - { projectName: 'project5', avgElapsedTime: 200.34 }, - ]; - - it('응답 속도 TOP5 데이터를 반환해야 한다', async () => { - mockLogService.getSpeedRank.mockResolvedValue(mockResponseDto); - - const result = await controller.getSpeedRank(mockRequestDto); - - expect(result).toEqual(mockResponseDto); - expect(result).toHaveLength(5); - expect(result[0]).toHaveProperty('projectName', 'project1'); - expect(result[0]).toHaveProperty('avgElapsedTime', 123.45); - expect(service.getSpeedRank).toHaveBeenCalledWith(mockRequestDto); - expect(service.getSpeedRank).toHaveBeenCalledTimes(1); - }); - - it('서비스 메소드 호출시 에러가 발생하면 예외를 throw 해야 한다', async () => { - const error = new Error('Database error'); - mockLogService.getSpeedRank.mockRejectedValue(error); - - await expect(controller.getSpeedRank(mockRequestDto)).rejects.toThrow(error); - - expect(service.getSpeedRank).toHaveBeenCalledWith(mockRequestDto); - expect(service.getSpeedRank).toHaveBeenCalledTimes(1); - }); - - it('데이터가 없을 때 빈 배열을 반환한다', async () => { - mockLogService.getSpeedRank.mockResolvedValue([]); - - const result = await controller.getSpeedRank(mockRequestDto); - - expect(result).toEqual([]); - expect(service.getSpeedRank).toHaveBeenCalledWith(mockRequestDto); - expect(service.getSpeedRank).toHaveBeenCalledTimes(1); - }); - }); -}); diff --git a/backend/console-server/src/log/log.controller.spec.ts b/backend/console-server/src/log/log.controller.spec.ts new file mode 100644 index 00000000..8f5e4348 --- /dev/null +++ b/backend/console-server/src/log/log.controller.spec.ts @@ -0,0 +1,67 @@ +import { Test } from '@nestjs/testing'; +import type { TestingModule } from '@nestjs/testing'; +import { LogController } from './log.controller'; +import { LogService } from './log.service'; + +describe('LogController 테스트', () => { + let controller: LogController; + let service: LogService; + + const mockLogService = { + getDAUByProject: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [LogController], + providers: [ + { + provide: LogService, + useValue: mockLogService, + }, + ], + }).compile(); + + controller = module.get(LogController); + service = module.get(LogService); + + jest.clearAllMocks(); + }); + + it('컨트롤러가 정의되어 있어야 한다', () => { + expect(controller).toBeDefined(); + }); + + describe('getDAUByProject()는', () => { + const mockRequestDto = { projectName: 'example-project', date: '2024-11-01' }; + + const mockResponseDto = { + projectName: 'example-project', + date: '2024-11-01', + dau: 125, + }; + + it('프로젝트명과 날짜가 들어왔을 때 DAU 데이터를 반환해야 한다', async () => { + mockLogService.getDAUByProject.mockResolvedValue(mockResponseDto); + + const result = await controller.getDAUByProject(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result).toHaveProperty('date', mockRequestDto.date); + expect(result).toHaveProperty('dau', 125); + expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getDAUByProject).toHaveBeenCalledTimes(1); + }); + + it('서비스 에러 시 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockLogService.getDAUByProject.mockRejectedValue(error); + + await expect(controller.getDAUByProject(mockRequestDto)).rejects.toThrow(error); + + expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getDAUByProject).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/console-server/src/log/log.controller.ts b/backend/console-server/src/log/log.controller.ts index 3d6f1527..798fa0a1 100644 --- a/backend/console-server/src/log/log.controller.ts +++ b/backend/console-server/src/log/log.controller.ts @@ -1,174 +1,13 @@ import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; import { LogService } from './log.service'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { GetPathSpeedRankDto } from './dto/get-path-speed-rank.dto'; -import { GetPathSpeedRankResponseDto } from './dto/get-path-speed-rank-response.dto'; -import { GetTrafficByProjectResponseDto } from './dto/get-traffic-by-project-response.dto'; -import { GetTrafficByProjectDto } from './dto/get-traffic-by-project.dto'; import { GetDAUByProjectResponseDto } from './dto/get-dau-by-project-response.dto'; import { GetDAUByProjectDto } from './dto/get-dau-by-project.dto'; -import { GetSuccessRateResponseDto } from './dto/get-success-rate-response.dto'; -import { GetSuccessRateDto } from './dto/get-success-rate.dto'; -import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; -import { GetSuccessRateByProjectDto } from './dto/get-success-rate-by-project.dto'; -import { GetAvgElapsedTimeResponseDto } from './dto/get-avg-elapsed-time-response.dto'; -import { GetTrafficRankResponseDto } from './dto/get-traffic-rank-response.dto'; -import { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generation-response.dto'; -import { GetSuccessRateByProjectResponseDto } from './dto/get-success-rate-by-project-response.dto'; -import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; -import { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; -import { GetSpeedRankDto } from './dto/get-speed-rank.dto'; -import { GetSpeedRankResponseDto } from './dto/get-speed-rank-response.dto'; -import { GetTrafficRankDto } from './dto/get-traffic-rank.dto'; -import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; -import { GetTrafficTop5ChartResponseDto } from './dto/get-traffic-top5-chart-response.dto'; -import { GetTrafficTop5ChartDto } from './dto/get-traffic-top5-chart.dto'; @Controller('log') export class LogController { constructor(private readonly logService: LogService) {} - @Get('/elapsed-time') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '기수 총 트래픽 평균 응답시간 API', - description: '요청받은 기수의 트래픽에 대한 평균 응답시간을 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '평균 응답시간이 성공적으로 반환됨.', - type: GetAvgElapsedTimeResponseDto, - }) - async getElapsedTime(@Query() getAvgElapsedTimeDto: GetAvgElapsedTimeDto) { - return await this.logService.getAvgElapsedTime(getAvgElapsedTimeDto); - } - - @Get('/traffic/rank') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '기수 내 트래픽 랭킹 TOP 5', - description: '요청받은 기수의 트래픽 랭킹 TOP 5를 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '트래픽 랭킹 TOP 5가 정상적으로 반환됨.', - type: GetTrafficRankResponseDto, - }) - async getTrafficRank(@Query() getTrafficRankDto: GetTrafficRankDto) { - return await this.logService.getTrafficRank(getTrafficRankDto); - } - - @Get('/elapsed-time/top5') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '기수 내 응답 속도 TOP5', - description: '요청받은 기수의 응답 속도 랭킹 TOP 5를 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '응답 속도 랭킹 TOP5가 정상적으로 반환됨.', - type: GetSpeedRankResponseDto, - }) - async getSpeedRank(@Query() getSpeedRankDto: GetSpeedRankDto) { - return await this.logService.getSpeedRank(getSpeedRankDto); - } - - @Get('/success-rate') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '기수 내 응답 성공률', - description: '요청받은 기수의 기수 내 응답 성공률를 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '기수 내 응답 성공률이 성공적으로 반환됨.', - type: GetSuccessRateResponseDto, - }) - async getResponseSuccessRate(@Query() getSuccessRateDto: GetSuccessRateDto) { - return await this.logService.getResponseSuccessRate(getSuccessRateDto); - } - - @Get('/success-rate/project') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '프로젝트 별 응답 성공률', - description: '요청받은 프로젝트의 응답 성공률을 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '프로젝트 별 응답 성공률이 성공적으로 반환됨.', - type: GetSuccessRateByProjectResponseDto, - }) - async getResponseSuccessRateByProject( - @Query() getSuccessRateByProjectDto: GetSuccessRateByProjectDto, - ) { - return await this.logService.getResponseSuccessRateByProject(getSuccessRateByProjectDto); - } - - @Get('/traffic/project') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '프로젝트 별 트래픽 조회', - description: '프로젝트 이름과 시간 단위로 특정 프로젝트의 트래픽 데이터를 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '특정 프로젝트의 트래픽 데이터가 반환됨.', - type: GetTrafficByProjectResponseDto, - }) - async getTrafficByProject(@Query() getTrafficByProjectDto: GetTrafficByProjectDto) { - return await this.logService.getTrafficByProject(getTrafficByProjectDto); - } - - @Get('/traffic') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '기수 내 총 트래픽', - description: ' 요청받은 기수의 기수 내 총 트래픽를 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '기수 내 총 트래픽이 정상적으로 반환됨.', - type: GetTrafficByGenerationResponseDto, - }) - async getTrafficByGeneration(@Query() getTrafficByGenerationDto: GetTrafficByGenerationDto) { - return await this.logService.getTrafficByGeneration(getTrafficByGenerationDto); - } - - @Get('/traffic/daily-difference') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '기수 별 프로젝트 전일 대비 트래픽', - description: '요청받은 기수의 프로젝트 전일 대비 트래픽을 반환합니다.', - }) - @ApiResponse({ - status: 200, - description: '기수별 프로젝트 전일 대비 트래픽이 정상적으로 반환됨', - type: GetTrafficDailyDifferenceResponseDto, - }) - async getTrafficDailyDifferenceByGeneration( - @Query() getTrafficDailyDifferenceDto: GetTrafficDailyDifferenceDto, - ) { - return await this.logService.getTrafficDailyDifferenceByGeneration( - getTrafficDailyDifferenceDto, - ); - } - - @Get('/elapsed-time/path-rank') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '개별 프로젝트의 경로별 응답 속도 순위', - description: '개별 프로젝트의 경로별 응답 속도 중 가장 빠른/느린 3개를 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '개별 프로젝트의 경로별 응답 속도 중 가장 빠른/느린 3개가 반환됨.', - type: GetPathSpeedRankResponseDto, - }) - async getPathSpeedRankByProject(@Query() getPathSpeedRankDto: GetPathSpeedRankDto) { - return await this.logService.getPathSpeedRankByProject(getPathSpeedRankDto); - } - @Get('/dau') @HttpCode(HttpStatus.OK) @ApiOperation({ @@ -183,19 +22,4 @@ export class LogController { async getDAUByProject(@Query() getDAUByProjectDto: GetDAUByProjectDto) { return await this.logService.getDAUByProject(getDAUByProjectDto); } - - @Get('/traffic/top5/line-chart') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '프로젝트 트래픽 TOP 5에 대한 트래픽 데이터 조회', - description: '프로젝트별 작일 데이터 전체 타임스탬프를 반환', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '프로젝트별 작일 데이터 전체 타임스탬프가 정상적으로 반환됨', - type: GetTrafficTop5ChartResponseDto, - }) - async getTrafficTop5Chart(@Query() getTrafficTop5ChartDto: GetTrafficTop5ChartDto) { - return await this.logService.getTrafficTop5Chart(getTrafficTop5ChartDto); - } } diff --git a/backend/console-server/src/log/log.module.ts b/backend/console-server/src/log/log.module.ts index 8a743c38..94ffcd15 100644 --- a/backend/console-server/src/log/log.module.ts +++ b/backend/console-server/src/log/log.module.ts @@ -1,14 +1,14 @@ import { Module } from '@nestjs/common'; -import { LogService } from './log.service'; import { LogController } from './log.controller'; -import { LogRepository } from './log.repository'; -import { Clickhouse } from '../clickhouse/clickhouse'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Project } from '../project/entities/project.entity'; +import { ClickhouseModule } from '../clickhouse/clickhouse.module'; +import { LogService } from './log.service'; +import { LogRepository } from './log.repository'; @Module({ - imports: [TypeOrmModule.forFeature([Project])], + imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], + providers: [LogService, LogRepository], controllers: [LogController], - providers: [LogService, LogRepository, Clickhouse], }) export class LogModule {} diff --git a/backend/console-server/src/log/log.repository.spec.ts b/backend/console-server/src/log/log.repository.spec.ts index d803c841..2e247585 100644 --- a/backend/console-server/src/log/log.repository.spec.ts +++ b/backend/console-server/src/log/log.repository.spec.ts @@ -34,193 +34,6 @@ describe('LogRepository 테스트', () => { expect(repository).toBeDefined(); }); - describe('findAvgElapsedTime()는 ', () => { - it('TimeSeriesQueryBuilder를 사용하여 올바른 쿼리를 생성해야 한다.', async () => { - const mockResult = [{ avg_elapsed_time: 150 }]; - mockClickhouse.query.mockResolvedValue(mockResult); - - const result = await repository.findAvgElapsedTime(); - - expect(result).toEqual(mockResult[0]); - expect(clickhouse.query).toHaveBeenCalledWith( - expect.stringMatching(/SELECT.*avg\(elapsed_time\)/), - expect.any(Object), - ); - }); - }); - - describe('findCountByHost()는 ', () => { - it('호스트별 요청 수를 내림차순으로 정렬하여 반환해야 한다.', async () => { - const mockResult = [ - { host: 'api.example.com', count: 1000 }, - { host: 'web.example.com', count: 500 }, - ]; - mockClickhouse.query.mockResolvedValue(mockResult); - - const result = await repository.findTop5CountByHost(); - - expect(result).toEqual(mockResult); - expect(clickhouse.query).toHaveBeenCalledWith( - expect.stringMatching(/GROUP BY.*host.*ORDER BY.*DESC/), - expect.any(Object), - ); - }); - }); - - describe('findResponseSuccessRate()는 ', () => { - it('성공률을 올바르게 계산할 수 있어야 한다.', async () => { - const mockQueryResult = [{ is_error_rate: 1.5 }]; - (clickhouse.query as jest.Mock).mockResolvedValue(mockQueryResult); - - const result = await repository.findResponseSuccessRate(); - - expect(result).toEqual({ success_rate: 98.5 }); - expect(clickhouse.query).toHaveBeenCalledWith( - 'SELECT (sum(is_error) / count(*)) * 100 as is_error_rate\nFROM http_log', - ); - }); - - it('에러율이 0%일 때 성공률 100%를 반환해야 한다.', async () => { - mockClickhouse.query.mockResolvedValue([{ is_error_rate: 0 }]); - - const result = await repository.findResponseSuccessRate(); - - expect(result).toEqual({ success_rate: 100 }); - }); - }); - - describe('findResponseSuccessRateByProject()는 ', () => { - const domain = 'example.com'; - - it('프로젝트별 성공률을 올바르게 계산할 수 있어야 한다', async () => { - const mockQueryResult = [{ is_error_rate: 1.5 }]; - (clickhouse.query as jest.Mock).mockResolvedValue(mockQueryResult); - - const result = await repository.findResponseSuccessRateByProject(domain); - - const expectedQuery = `SELECT (sum(is_error) / count(*)) * 100 as is_error_rate -FROM (SELECT is_error, timestamp -FROM http_log WHERE host = {host:String} ORDER BY timestamp DESC LIMIT 1000) as subquery`; - - expect(result).toEqual({ success_rate: 98.5 }); - expect(clickhouse.query).toHaveBeenCalledWith( - expectedQuery, - expect.objectContaining({ host: domain }), - ); - }); - - it('에러율이 0%일 때 성공률 100%를 반환해야 한다', async () => { - (clickhouse.query as jest.Mock).mockResolvedValue([{ is_error_rate: 0 }]); - - const result = await repository.findResponseSuccessRateByProject(domain); - - expect(result).toEqual({ success_rate: 100 }); - }); - - it('쿼리 에러 발생시 예외를 throw 해야 한다', async () => { - const error = new Error('Clickhouse connection error'); - (clickhouse.query as jest.Mock).mockRejectedValue(error); - - await expect(repository.findResponseSuccessRateByProject(domain)).rejects.toThrow( - 'Clickhouse connection error', - ); - }); - }); - - describe('findTrafficByGeneration()는 ', () => { - it('전체 트래픽 수를 반환해야 한다.', async () => { - const mockResult = { count: 5000 }; - - mockClickhouse.query.mockResolvedValue([mockResult]); - const result = await repository.findTrafficByGeneration(); - - expect(result).toEqual(mockResult); - expect(clickhouse.query).toHaveBeenCalledWith( - expect.stringMatching(/SELECT.*count\(\).*as count/s), - expect.any(Object), - ); - }); - }); - - describe('getTrafficByProject()는', () => { - const domain = 'example.com'; - const timeUnit = 'Hour'; - - const mockTrafficData = [ - { timestamp: '2024-11-18 10:00:00', count: 150 }, - { timestamp: '2024-11-18 11:00:00', count: 120 }, - { timestamp: '2024-11-18 12:00:00', count: 180 }, - ]; - - it('올바른 도메인과 시간 단위를 기준으로 트래픽 데이터를 반환해야 한다.', async () => { - mockClickhouse.query.mockResolvedValue(mockTrafficData); - - const result = await repository.findTrafficByProject(domain, timeUnit); - - expect(result).toEqual(mockTrafficData); - expect(clickhouse.query).toHaveBeenCalledWith( - expect.stringMatching( - /SELECT\s+count\(\)\s+as\s+count,\s+toStartOfHour\(timestamp\)\s+as\s+timestamp\s+FROM\s+http_log\s+WHERE\s+host\s+=\s+\{host:String}\s+GROUP\s+BY\s+timestamp\s+ORDER\s+BY\s+timestamp/s, - ), - expect.objectContaining({ host: domain }), - ); - }); - - it('트래픽 데이터가 없을 경우 빈 배열을 반환해야 한다.', async () => { - mockClickhouse.query.mockResolvedValue([]); - - const result = await repository.findTrafficByProject(domain, timeUnit); - - expect(result).toEqual([]); - expect(clickhouse.query).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ host: domain }), - ); - }); - - it('Clickhouse 호출 중 에러가 발생하면 예외를 throw 해야 한다.', async () => { - const error = new Error('Clickhouse query failed'); - mockClickhouse.query.mockRejectedValue(error); - - await expect(repository.findTrafficByProject(domain, timeUnit)).rejects.toThrow( - 'Clickhouse query failed', - ); - - expect(clickhouse.query).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ host: domain }), - ); - }); - }); - - describe('findTrafficForTimeRange()는 ', () => { - const mockDate = new Date('2024-03-20T15:00:00Z'); - - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(mockDate); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('특정 기간의 트래픽을 리턴할 수 있어야 한다.', async () => { - const mockTraffic = [{ count: 500 }]; - const timeRange = { - start: new Date('2024-01-02T00:00:00Z'), - end: new Date('2024-01-02T23:59:59Z'), - }; - - mockClickhouse.query.mockResolvedValue(mockTraffic); - - const result = await repository.findTrafficForTimeRange(timeRange.start, timeRange.end); - - expect(result).toEqual(mockTraffic); - expect(clickhouse.query).toHaveBeenCalledWith(expect.any(String), expect.any(Object)); - }); - }); - describe('getDAUByProject()', () => { const domain = 'example.com'; const date = '2024-11-18'; diff --git a/backend/console-server/src/log/log.repository.ts b/backend/console-server/src/log/log.repository.ts index 8bc77a6d..fc50d98f 100644 --- a/backend/console-server/src/log/log.repository.ts +++ b/backend/console-server/src/log/log.repository.ts @@ -1,177 +1,11 @@ import { Clickhouse } from '../clickhouse/clickhouse'; import { Injectable } from '@nestjs/common'; import { TimeSeriesQueryBuilder } from '../clickhouse/query-builder/time-series.query-builder'; -import { TrafficRankMetric } from './metric/traffic-rank.metric'; -import { AvgElapsedTimeMetric } from './metric/avg-elapsed-time.metric'; -import { plainToInstance } from 'class-transformer'; -import { TrafficRankTop5Metric } from './metric/traffic-rank-top5.metric'; -import { TrafficCountMetric } from './metric/traffic-count.metric'; -import { ErrorRateMetric } from './metric/error-rate.metric'; -import { SuccessRateMetric } from './metric/success-rate.metric'; -import { ElapsedTimeByPathMetric } from './metric/elapsed-time-by-path.metric'; -import { SpeedRankMetric } from './metric/speed-rank.metric'; -import { TrafficChartMetric } from './metric/trafficChart.metric'; @Injectable() export class LogRepository { constructor(private readonly clickhouse: Clickhouse) {} - async findAvgElapsedTime() { - const { query, params } = new TimeSeriesQueryBuilder() - .metrics([{ name: 'elapsed_time', aggregation: 'avg' }]) - .from('http_log') - .build(); - - const [result] = await this.clickhouse.query(query, params); - - return plainToInstance(AvgElapsedTimeMetric, result); - } - - async findTop5CountByHost() { - const { query, params } = new TimeSeriesQueryBuilder() - .metrics([ - { - name: 'host', - }, - { - name: '*', - aggregation: 'count', - }, - ]) - .from('http_log') - .groupBy(['host']) - .orderBy(['count'], true) - .limit(5) - .build(); - - const results = await this.clickhouse.query(query, params); - - return plainToInstance( - TrafficRankTop5Metric, - results.map((result) => plainToInstance(TrafficRankMetric, result)), - ); - } - - async findResponseSuccessRate() { - const { query } = new TimeSeriesQueryBuilder() - .metrics([ - { - name: 'is_error', - aggregation: 'rate', - }, - ]) - .from('http_log') - .build(); - - const [result] = await this.clickhouse.query(query); - - return plainToInstance(SuccessRateMetric, { - success_rate: 100 - result.is_error_rate, - }); - } - - async findResponseSuccessRateByProject(domain: string) { - const subQueryBuilder = new TimeSeriesQueryBuilder() - .metrics([{ name: 'is_error' }, { name: 'timestamp' }]) - .from('http_log') - .filter({ host: domain }) - .orderBy(['timestamp'], true) - .limit(1000) - .build(); - - const mainQueryBuilder = new TimeSeriesQueryBuilder() - .metrics([ - { - name: 'is_error', - aggregation: 'rate', - }, - ]) - .from(`(${subQueryBuilder.query}) as subquery`) - .build(); - - const [result] = await this.clickhouse.query( - mainQueryBuilder.query, - subQueryBuilder.params, - ); - - return plainToInstance(SuccessRateMetric, { - success_rate: 100 - result.is_error_rate, - }); - } - - async findTrafficByGeneration() { - const { query, params } = new TimeSeriesQueryBuilder() - .metrics([ - { - name: '*', - aggregation: 'count', - }, - ]) - .from('http_log') - .build(); - - const [result] = await this.clickhouse.query(query, params); - - return plainToInstance(TrafficCountMetric, result); - } - - async findTrafficForTimeRange(start: Date, end: Date) { - const queryBuilder = new TimeSeriesQueryBuilder() - .metrics([{ name: '*', aggregation: 'count' }]) - .from('http_log') - .timeBetween(start, end) - .build(); - - return this.clickhouse.query<{ count: number }>(queryBuilder.query, queryBuilder.params); - } - - async getFastestPathsByDomain(domain: string) { - const { query, params } = new TimeSeriesQueryBuilder() - .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'path' }]) - .from('http_log') - .filter({ host: domain }) - .groupBy(['path']) - .orderBy(['avg_elapsed_time'], false) - .limit(3) - .build(); - - const results = await this.clickhouse.query(query, params); - - return results.map((result) => plainToInstance(ElapsedTimeByPathMetric, result)); - } - - async findSlowestPathsByDomain(domain: string) { - const { query, params } = new TimeSeriesQueryBuilder() - .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'path' }]) - .from('http_log') - .filter({ host: domain }) - .groupBy(['path']) - .orderBy(['avg_elapsed_time'], true) - .limit(3) - .build(); - - const results = await this.clickhouse.query(query, params); - - return results.map((result) => plainToInstance(ElapsedTimeByPathMetric, result)); - } - - async findTrafficByProject(domain: string, timeUnit: string) { - const { query, params } = new TimeSeriesQueryBuilder() - .metrics([ - { name: '*', aggregation: 'count' }, - { name: `toStartOf${timeUnit}(timestamp) as timestamp` }, - ]) - .from('http_log') - .filter({ host: domain }) - .groupBy(['timestamp']) - .orderBy(['timestamp'], false) - .build(); - - const results = await this.clickhouse.query(query, params); - - return results.map((result) => plainToInstance(TrafficCountMetric, result)); - } - async findDAUByProject(domain: string, date: string) { const { query, params } = new TimeSeriesQueryBuilder() .metrics([{ name: `SUM(access) as dau` }]) @@ -182,62 +16,4 @@ export class LogRepository { const [result] = await this.clickhouse.query<{ dau: number }>(query, params); return result?.dau ? result.dau : 0; } - - async findSpeedRank() { - const { query, params } = new TimeSeriesQueryBuilder() - .metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'host' }]) - .from('http_log') - .groupBy(['host']) - .orderBy(['avg_elapsed_time'], false) - .limit(5) - .build(); - const results = await this.clickhouse.query(query, params); - return results.map((result) => plainToInstance(SpeedRankMetric, result)); - } - - async findTrafficTop5Chart() { - const now = new Date(); - const today = new Date(now.setHours(0, 0, 0, 0)); - const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); - - const query = `WITH top_hosts AS ( - SELECT host - FROM http_log - WHERE timestamp >= {startTime: DateTime64(3)} - AND timestamp < {endTime: DateTime64(3)} - GROUP BY host - ORDER BY count() DESC - LIMIT 5 - ) - SELECT - host, - groupArray( - ( - toDateTime64(toStartOfInterval(timestamp, INTERVAL 1 MINUTE), 0), - requests_count - ) - ) as traffic - FROM ( - SELECT - host, - toDateTime64(toStartOfInterval(timestamp, INTERVAL 1 MINUTE), 0) as timestamp, - count() as requests_count - FROM http_log - WHERE timestamp >= {startTime: DateTime64(3)} - AND timestamp < {endTime: DateTime64(3)} - AND host IN (SELECT host FROM top_hosts) - GROUP BY - host, - timestamp - ORDER BY - timestamp - ) - GROUP BY host;`; - const params = { startTime: yesterday, endTime: today }; - const results = await this.clickhouse.query(query, params); - - return results.map((result) => { - return plainToInstance(TrafficChartMetric, result); - }); - } } diff --git a/backend/console-server/src/log/log.service.spec.ts b/backend/console-server/src/log/log.service.spec.ts index 40f551a4..67969cff 100644 --- a/backend/console-server/src/log/log.service.spec.ts +++ b/backend/console-server/src/log/log.service.spec.ts @@ -5,14 +5,13 @@ import { LogRepository } from './log.repository'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Project } from '../project/entities/project.entity'; import { NotFoundException } from '@nestjs/common'; -import type { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generation-response.dto'; -import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; -import { GetSuccessRateByProjectResponseDto } from './dto/get-success-rate-by-project-response.dto'; -import type { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; -import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; +import type { GetTrafficByGenerationResponseDto } from './traffic/dto/get-traffic-by-generation-response.dto'; +import { GetTrafficByGenerationDto } from './traffic/dto/get-traffic-by-generation.dto'; +import { GetProjectSuccessRateResponseDto } from './success-rate/dto/get-project-success-rate-response.dto'; +import type { GetTrafficDailyDifferenceDto } from './traffic/dto/get-traffic-daily-difference.dto'; +import { GetTrafficDailyDifferenceResponseDto } from './traffic/dto/get-traffic-daily-difference-response.dto'; import { plainToInstance } from 'class-transformer'; -import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; -import { GetTrafficRankDto } from './dto/get-traffic-rank.dto'; +import { GetTrafficTop5Dto } from './traffic/dto/get-traffic-top5.dto'; describe('LogService 테스트', () => { let service: LogService; @@ -57,349 +56,6 @@ describe('LogService 테스트', () => { expect(service).toBeDefined(); }); - describe('elapsedTime()는 ', () => { - it('평균 응답 시간을 반환할 수 있어야 한다.', async () => { - const mockTime = { avg_elapsed_time: 150 }; - mockLogRepository.findAvgElapsedTime.mockResolvedValue(mockTime); - - const result = await service.getAvgElapsedTime( - plainToInstance(GetAvgElapsedTimeDto, { generation: 9 }), - ); - - expect(result).toEqual(mockTime); - expect(repository.findAvgElapsedTime).toHaveBeenCalled(); - }); - }); - - describe('trafficRank()는 ', () => { - it('top 5 traffic rank를 리턴할 수 있어야 한다.', async () => { - const mockRanks = [ - { host: 'api1.example.com', count: 1000 }, - { host: 'api2.example.com', count: 800 }, - { host: 'api3.example.com', count: 600 }, - { host: 'api4.example.com', count: 400 }, - { host: 'api5.example.com', count: 200 }, - ]; - mockLogRepository.findTop5CountByHost.mockResolvedValue(mockRanks); - - const result = await service.getTrafficRank( - plainToInstance(GetTrafficRankDto, { generation: 9 }), - ); - - expect(result).toHaveLength(5); - expect(result).toEqual(mockRanks.slice(0, 5)); - expect(repository.findTop5CountByHost).toHaveBeenCalled(); - }); - - it('5개 이하의 결과에 대해서 올바르게 처리할 수 있어야 한다.', async () => { - const mockRanks = [ - { host: 'api1.example.com', count: 1000 }, - { host: 'api2.example.com', count: 800 }, - ]; - mockLogRepository.findTop5CountByHost.mockResolvedValue(mockRanks); - - const result = await service.getTrafficRank( - plainToInstance(GetTrafficRankDto, { generation: 9 }), - ); - - expect(result).toHaveLength(2); - expect(result).toEqual(mockRanks); - }); - }); - - describe('responseSuccessRate()는 ', () => { - it('응답 성공률을 반환할 수 있어야 한다.', async () => { - const mockSuccessRateDto = { generation: 9 }; - const mockRepositoryResponse = { success_rate: 98.5 }; - mockLogRepository.findResponseSuccessRate.mockResolvedValue(mockRepositoryResponse); - const expectedResult = { success_rate: 98.5 }; - - const result = await service.getResponseSuccessRate(mockSuccessRateDto); - - expect(result).toEqual(expectedResult); - expect(repository.findResponseSuccessRate).toHaveBeenCalled(); - }); - }); - - describe('getResponseSuccessRateByProject()는 ', () => { - const mockRequestDto = { projectName: 'example-project' }; - const mockProject = { - name: 'example-project', - domain: 'example.com', - }; - const mockSuccessRate = { success_rate: 95.5 }; - - it('projectName을 이용해 도메인을 조회한 후 응답 성공률을 반환해야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); - - mockLogRepository.findResponseSuccessRateByProject = jest - .fn() - .mockResolvedValue(mockSuccessRate); - - const result = await service.getResponseSuccessRateByProject(mockRequestDto); - - expect(projectRepository.findOne).toHaveBeenCalledWith({ - where: { name: mockRequestDto.projectName }, - select: ['domain'], - }); - expect(mockLogRepository.findResponseSuccessRateByProject).toHaveBeenCalledWith( - mockProject.domain, - ); - expect(result).toEqual( - expect.objectContaining({ - success_rate: mockSuccessRate.success_rate, - }), - ); - }); - - it('응답이 GetSuccessRateByProjectResponseDTO 형태로 변환되어야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); - - mockLogRepository.findResponseSuccessRateByProject.mockResolvedValue(mockSuccessRate); - - const result = await service.getResponseSuccessRateByProject(mockRequestDto); - - expect(result).toBeInstanceOf(GetSuccessRateByProjectResponseDto); - expect(Object.keys(result)).toContain('projectName'); - expect(Object.keys(result)).toContain('success_rate'); - expect(Object.keys(result).length).toBe(2); - expect(result.projectName).toBe(mockRequestDto.projectName); - expect(result.success_rate).toBe(mockSuccessRate.success_rate); - }); - - it('존재하지 않는 프로젝트명을 받은 경우, NotFoundException을 던져야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(null); - - await expect(service.getResponseSuccessRateByProject(mockRequestDto)).rejects.toThrow( - new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), - ); - - expect(projectRepository.findOne).toHaveBeenCalledWith({ - where: { name: mockRequestDto.projectName }, - select: ['domain'], - }); - expect(mockLogRepository.findResponseSuccessRateByProject).not.toHaveBeenCalled(); - }); - - it('레포지토리 호출 시, 에러가 발생하면, 예외를 throw 해야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); - - mockLogRepository.findResponseSuccessRateByProject = jest - .fn() - .mockRejectedValue(new Error('Database error')); - - await expect(service.getResponseSuccessRateByProject(mockRequestDto)).rejects.toThrow( - 'Database error', - ); - - expect(projectRepository.findOne).toHaveBeenCalledWith({ - where: { name: mockRequestDto.projectName }, - select: ['domain'], - }); - expect(mockLogRepository.findResponseSuccessRateByProject).toHaveBeenCalledWith( - mockProject.domain, - ); - }); - }); - - describe('trafficByGeneration()는 ', () => { - it('기수별 트래픽의 총합을 올바르게 반환할 수 있어야 한다.', async () => { - const mockRepositoryResponse = { count: 1000 }; - const expectedResponse: GetTrafficByGenerationResponseDto = { - count: 1000, - }; - mockLogRepository.findTrafficByGeneration.mockResolvedValue(mockRepositoryResponse); - - const result = await service.getTrafficByGeneration( - plainToInstance(GetTrafficByGenerationDto, { generation: 9 }), - ); - - expect(result).toEqual(expectedResponse); - expect(mockLogRepository.findTrafficByGeneration).toHaveBeenCalledTimes(1); - expect(mockLogRepository.findTrafficByGeneration).toHaveBeenCalled(); - }); - }); - - describe('getTrafficByProject()는', () => { - const mockRequestDto = { projectName: 'example-project', timeUnit: 'month' }; - const mockProject = { - name: 'example-project', - domain: 'example.com', - }; - const mockTrafficData = [ - { timestamp: '2024-11-01', count: 14 }, - { timestamp: '2024-10-01', count: 10 }, - ]; - const mockResponseDto = { - projectName: 'example-project', - timeUnit: 'month', - trafficData: mockTrafficData, - }; - - it('프로젝트명을 기준으로 도메인을 조회한 후 트래픽 데이터를 반환해야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); - - mockLogRepository.findTrafficByProject = jest.fn().mockResolvedValue(mockTrafficData); - - const result = await service.getTrafficByProject(mockRequestDto); - - expect(projectRepository.findOne).toHaveBeenCalledWith({ - where: { name: mockRequestDto.projectName }, - select: ['domain'], - }); - expect(mockLogRepository.findTrafficByProject).toHaveBeenCalledWith( - mockProject.domain, - mockRequestDto.timeUnit, - ); - expect(result).toEqual(mockResponseDto); - }); - - it('존재하지 않는 프로젝트명을 조회할 경우 NotFoundException을 던져야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(null); - - await expect(service.getTrafficByProject(mockRequestDto)).rejects.toThrow( - new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), - ); - - expect(projectRepository.findOne).toHaveBeenCalledWith({ - where: { name: mockRequestDto.projectName }, - select: ['domain'], - }); - expect(mockLogRepository.findTrafficByProject).not.toHaveBeenCalled(); - }); - - it('로그 레포지토리 호출 중 에러가 발생할 경우 예외를 던져야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); - - mockLogRepository.findTrafficByProject = jest - .fn() - .mockRejectedValue(new Error('Database error')); - - await expect(service.getTrafficByProject(mockRequestDto)).rejects.toThrow( - 'Database error', - ); - - expect(projectRepository.findOne).toHaveBeenCalledWith({ - where: { name: mockRequestDto.projectName }, - select: ['domain'], - }); - expect(mockLogRepository.findTrafficByProject).toHaveBeenCalledWith( - mockProject.domain, - mockRequestDto.timeUnit, - ); - }); - - it('트래픽 데이터가 없을 경우 빈 배열을 반환해야 한다', async () => { - const projectRepository = service['projectRepository']; - projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); - - mockLogRepository.findTrafficByProject = jest.fn().mockResolvedValue([]); - - const result = await service.getTrafficByProject(mockRequestDto); - - expect(projectRepository.findOne).toHaveBeenCalledWith({ - where: { name: mockRequestDto.projectName }, - select: ['domain'], - }); - expect(mockLogRepository.findTrafficByProject).toHaveBeenCalledWith( - mockProject.domain, - mockRequestDto.timeUnit, - ); - expect(result).toEqual({ - projectName: mockRequestDto.projectName, - timeUnit: mockRequestDto.timeUnit, - trafficData: [], - }); - }); - }); - - describe('getTrafficDailyDifferenceByGeneration()는 ', () => { - const mockRequestDto: GetTrafficDailyDifferenceDto = { generation: 9 }; - let mockDate: Date; - - beforeEach(() => { - mockDate = new Date('2024-03-20T15:00:00Z'); - jest.useFakeTimers(); - jest.setSystemTime(mockDate); - (mockLogRepository.findTrafficForTimeRange as jest.Mock).mockReset(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('전일 대비 총 트래픽 증감량을 리턴해야 한다', async () => { - const todayTraffic = [{ count: 10000 }]; - const yesterdayTraffic = [{ count: 900 }]; - - (mockLogRepository.findTrafficForTimeRange as jest.Mock) - .mockResolvedValueOnce(todayTraffic) - .mockResolvedValueOnce(yesterdayTraffic); - - const result = await service.getTrafficDailyDifferenceByGeneration(mockRequestDto); - - expect(result).toBeInstanceOf(GetTrafficDailyDifferenceResponseDto); - expect(result.traffic_daily_difference).toBe('+9100'); - expect(mockLogRepository.findTrafficForTimeRange).toHaveBeenCalledTimes(2); - }); - - it('트래픽의 차이가 0인 경우에도 올바르게 처리해야 한다', async () => { - const sameTraffic = [{ count: 500 }]; - - (mockLogRepository.findTrafficForTimeRange as jest.Mock) - .mockResolvedValueOnce(sameTraffic) - .mockResolvedValueOnce(sameTraffic); - - const result = await service.getTrafficDailyDifferenceByGeneration(mockRequestDto); - - expect(result).toBeInstanceOf(GetTrafficDailyDifferenceResponseDto); - expect(result.traffic_daily_difference).toBe('0'); - }); - - it('레포지토리 호출 시, 발생하는 에러를 throw 해야 한다', async () => { - (mockLogRepository.findTrafficForTimeRange as jest.Mock).mockRejectedValue( - new Error('Database error'), - ); - - await expect( - service.getTrafficDailyDifferenceByGeneration(mockRequestDto), - ).rejects.toThrow('Database error'); - }); - - it('시간 범위가 올바르게 계산되어야 한다', async () => { - const mockTraffic = [{ count: 100 }]; - (mockLogRepository.findTrafficForTimeRange as jest.Mock).mockResolvedValue(mockTraffic); - - const todayStart = new Date(mockDate); - todayStart.setHours(0, 0, 0, 0); - - const yesterdayStart = new Date(mockDate); - yesterdayStart.setDate(yesterdayStart.getDate() - 1); - yesterdayStart.setHours(0, 0, 0, 0); - - const yesterdayEnd = new Date(todayStart); - await service.getTrafficDailyDifferenceByGeneration(mockRequestDto); - - expect(mockLogRepository.findTrafficForTimeRange).toHaveBeenNthCalledWith( - 1, - todayStart, - expect.any(Date), - ); - expect(mockLogRepository.findTrafficForTimeRange).toHaveBeenNthCalledWith( - 2, - yesterdayStart, - yesterdayEnd, - ); - }); - }); - describe('getDAUByProject()는', () => { const mockRequestDto = { projectName: 'example-project', date: '2024-11-01' }; const mockProject = { diff --git a/backend/console-server/src/log/log.service.ts b/backend/console-server/src/log/log.service.ts index 19a342ce..976f7442 100644 --- a/backend/console-server/src/log/log.service.ts +++ b/backend/console-server/src/log/log.service.ts @@ -4,37 +4,8 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Project } from '../project/entities/project.entity'; import { Repository } from 'typeorm'; import { LogRepository } from './log.repository'; -import { GetPathSpeedRankDto } from './dto/get-path-speed-rank.dto'; -import { - GetPathSpeedRankResponseDto, - ResponseTimeByPath, -} from './dto/get-path-speed-rank-response.dto'; -import { GetTrafficByProjectDto } from './dto/get-traffic-by-project.dto'; -import { - GetTrafficByProjectResponseDto, - TrafficCountByTimeunit, -} from './dto/get-traffic-by-project-response.dto'; import { GetDAUByProjectDto } from './dto/get-dau-by-project.dto'; import { GetDAUByProjectResponseDto } from './dto/get-dau-by-project-response.dto'; -import { GetSuccessRateResponseDto } from './dto/get-success-rate-response.dto'; -import { GetSuccessRateDto } from './dto/get-success-rate.dto'; -import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; -import { GetSuccessRateByProjectResponseDto } from './dto/get-success-rate-by-project-response.dto'; -import { GetSuccessRateByProjectDto } from './dto/get-success-rate-by-project.dto'; -import { GetAvgElapsedTimeResponseDto } from './dto/get-avg-elapsed-time-response.dto'; -import { GetTrafficRankResponseDto } from './dto/get-traffic-rank-response.dto'; -import { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generation-response.dto'; -import { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; -import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; -import { GetSpeedRankDto } from './dto/get-speed-rank.dto'; -import { GetSpeedRankResponseDto } from './dto/get-speed-rank-response.dto'; -import { GetTrafficRankDto } from './dto/get-traffic-rank.dto'; -import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; -import { - GetTrafficTop5ChartResponseDto, - TrafficTop5Chart, -} from './dto/get-traffic-top5-chart-response.dto'; -import { GetTrafficTop5ChartDto } from './dto/get-traffic-top5-chart.dto'; @Injectable() export class LogService { @@ -44,141 +15,6 @@ export class LogService { private readonly logRepository: LogRepository, ) {} - async getAvgElapsedTime(_getAvgElapsedTime: GetAvgElapsedTimeDto) { - const result = await this.logRepository.findAvgElapsedTime(); - - return plainToInstance(GetAvgElapsedTimeResponseDto, result); - } - - async getTrafficRank(_getTrafficRankDto: GetTrafficRankDto) { - const result = await this.logRepository.findTop5CountByHost(); - - return plainToInstance(GetTrafficRankResponseDto, result); - } - - async getResponseSuccessRate(_getSuccessRateDto: GetSuccessRateDto) { - const result = await this.logRepository.findResponseSuccessRate(); - - return plainToInstance(GetSuccessRateResponseDto, result); - } - - async getResponseSuccessRateByProject(getSuccessRateByProjectDto: GetSuccessRateByProjectDto) { - const { projectName } = getSuccessRateByProjectDto; - - const project = await this.projectRepository.findOne({ - where: { name: projectName }, - select: ['domain'], - }); - - if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); - - const result = await this.logRepository.findResponseSuccessRateByProject(project.domain); - - return plainToInstance(GetSuccessRateByProjectResponseDto, { - projectName, - ...result, - }); - } - - async getTrafficByGeneration(_getTrafficByGenerationDto: GetTrafficByGenerationDto) { - const result = await this.logRepository.findTrafficByGeneration(); - - return plainToInstance(GetTrafficByGenerationResponseDto, result); - } - - private calculateTimeRanges() { - const todayStart = new Date(); - todayStart.setHours(0, 0, 0, 0); - - const yesterdayStart = new Date(); - yesterdayStart.setDate(yesterdayStart.getDate() - 1); - yesterdayStart.setHours(0, 0, 0, 0); - - const yesterdayEnd = new Date(todayStart); - - return { - today: { start: todayStart, end: new Date() }, - yesterday: { start: yesterdayStart, end: yesterdayEnd }, - }; - } - - private async fetchTrafficData(timeRange: { start: Date; end: Date }) { - const result = await this.logRepository.findTrafficForTimeRange( - timeRange.start, - timeRange.end, - ); - return result[0].count; - } - - private formatTrafficDifference(difference: number): string { - return difference > 0 ? `+${difference}` : `${difference}`; - } - - async getTrafficDailyDifferenceByGeneration( - _getTrafficDailyDifferenceDto: GetTrafficDailyDifferenceDto, - ) { - const timeRanges = this.calculateTimeRanges(); - - const [today, yesterday] = await Promise.all([ - this.fetchTrafficData(timeRanges.today), - this.fetchTrafficData(timeRanges.yesterday), - ]); - - const difference = today - yesterday; - const result = { - traffic_daily_difference: this.formatTrafficDifference(difference), - }; - - return plainToInstance(GetTrafficDailyDifferenceResponseDto, result); - } - - async getPathSpeedRankByProject(getPathSpeedRankDto: GetPathSpeedRankDto) { - const { projectName } = getPathSpeedRankDto; - const project = await this.projectRepository.findOne({ - where: { name: projectName }, - select: ['domain'], - }); - - if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); - - const fastestPaths = await this.logRepository.getFastestPathsByDomain(project.domain); - const slowestPaths = await this.logRepository.findSlowestPathsByDomain(project.domain); - - return plainToInstance(GetPathSpeedRankResponseDto, { - projectName, - fastestPaths: fastestPaths.map((fastestPath) => - plainToInstance(ResponseTimeByPath, { - path: fastestPath.path, - avgResponseTime: fastestPath.avg_elapsed_time, - }), - ), - slowestPaths: slowestPaths.map((slowestPath) => - plainToInstance(ResponseTimeByPath, { - path: slowestPath.path, - avgResponseTime: slowestPath.avg_elapsed_time, - }), - ), - }); - } - - async getTrafficByProject(getTrafficByProjectDto: GetTrafficByProjectDto) { - const { projectName, timeUnit } = getTrafficByProjectDto; - - const project = await this.projectRepository.findOne({ - where: { name: projectName }, - select: ['domain'], - }); - if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); - - const results = await this.logRepository.findTrafficByProject(project.domain, timeUnit); - - return plainToInstance(GetTrafficByProjectResponseDto, { - projectName, - timeUnit, - trafficData: results.map((result) => plainToInstance(TrafficCountByTimeunit, result)), - }); - } - async getDAUByProject(getDAUByProjectDto: GetDAUByProjectDto) { const { projectName, date } = getDAUByProjectDto; @@ -196,46 +32,4 @@ export class LogService { dau, }); } - - async getSpeedRank(_getSpeedRankDto: GetSpeedRankDto) { - const speedRankData = await this.logRepository.findSpeedRank(); - const response = await Promise.all( - speedRankData.map(async (data) => { - const project = await this.projectRepository.findOne({ - where: { domain: data.host }, - select: ['name'], - }); - return { - projectName: project?.name || 'Unknown', - avgResponseTime: data.avg_elapsed_time, - }; - }), - ); - - return plainToInstance(GetSpeedRankResponseDto, response); - } - - async getTrafficTop5Chart(_getTrafficTop5ChartDto: GetTrafficTop5ChartDto) { - const results = await this.logRepository.findTrafficTop5Chart(); - - const trafficCharts = await Promise.all( - results.map(async (result) => { - const host = result.host; - const project = await this.projectRepository - .createQueryBuilder('project') - .select('project.name') - .where('project.domain = :domain', { domain: host }) - .getOne(); - - const projectName = project?.name; - - return plainToInstance(TrafficTop5Chart, { - name: projectName, - traffic: result.traffic, - }); - }), - ); - - return plainToInstance(GetTrafficTop5ChartResponseDto, trafficCharts); - } } diff --git a/backend/console-server/src/log/dto/get-success-rate-by-project-response.dto.ts b/backend/console-server/src/log/success-rate/dto/get-project-success-rate-response.dto.ts similarity index 89% rename from backend/console-server/src/log/dto/get-success-rate-by-project-response.dto.ts rename to backend/console-server/src/log/success-rate/dto/get-project-success-rate-response.dto.ts index 935617fe..b07c961b 100644 --- a/backend/console-server/src/log/dto/get-success-rate-by-project-response.dto.ts +++ b/backend/console-server/src/log/success-rate/dto/get-project-success-rate-response.dto.ts @@ -1,7 +1,7 @@ import { Expose, Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; -export class GetSuccessRateByProjectResponseDto { +export class GetProjectSuccessRateResponseDto { @ApiProperty({ description: '프로젝트의 이름', example: 'watchducks', diff --git a/backend/console-server/src/log/dto/get-success-rate-by-project.dto.ts b/backend/console-server/src/log/success-rate/dto/get-project-success-rate.dto.ts similarity index 86% rename from backend/console-server/src/log/dto/get-success-rate-by-project.dto.ts rename to backend/console-server/src/log/success-rate/dto/get-project-success-rate.dto.ts index f5f94d27..65ef3fae 100644 --- a/backend/console-server/src/log/dto/get-success-rate-by-project.dto.ts +++ b/backend/console-server/src/log/success-rate/dto/get-project-success-rate.dto.ts @@ -1,7 +1,7 @@ import { IsString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -export class GetSuccessRateByProjectDto { +export class GetProjectSuccessRateDto { @IsString() @ApiProperty({ description: '프로젝트 이름', diff --git a/backend/console-server/src/log/dto/get-success-rate-response.dto.ts b/backend/console-server/src/log/success-rate/dto/get-success-rate-response.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-success-rate-response.dto.ts rename to backend/console-server/src/log/success-rate/dto/get-success-rate-response.dto.ts diff --git a/backend/console-server/src/log/dto/get-success-rate.dto.ts b/backend/console-server/src/log/success-rate/dto/get-success-rate.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-success-rate.dto.ts rename to backend/console-server/src/log/success-rate/dto/get-success-rate.dto.ts diff --git a/backend/console-server/src/log/metric/error-rate.metric.ts b/backend/console-server/src/log/success-rate/metric/error-rate.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/error-rate.metric.ts rename to backend/console-server/src/log/success-rate/metric/error-rate.metric.ts diff --git a/backend/console-server/src/log/metric/success-rate.metric.ts b/backend/console-server/src/log/success-rate/metric/success-rate.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/success-rate.metric.ts rename to backend/console-server/src/log/success-rate/metric/success-rate.metric.ts diff --git a/backend/console-server/src/log/success-rate/success-rate.controller.spec.ts b/backend/console-server/src/log/success-rate/success-rate.controller.spec.ts new file mode 100644 index 00000000..f2abe0e4 --- /dev/null +++ b/backend/console-server/src/log/success-rate/success-rate.controller.spec.ts @@ -0,0 +1,95 @@ +import { SuccessRateController } from './success-rate.controller'; +import { SuccessRateService } from './success-rate.service'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +describe('SuccessRateController 테스트', () => { + let controller: SuccessRateController; + let service: SuccessRateService; + + const mockSuccessRateService = { + getSuccessRate: jest.fn(), + getProjectSuccessRate: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SuccessRateController], + providers: [ + { + provide: SuccessRateService, + useValue: mockSuccessRateService, + }, + ], + }).compile(); + + controller = module.get(SuccessRateController); + service = module.get(SuccessRateService); + + jest.clearAllMocks(); + }); + + it('컨트롤러가 정의되어 있어야 한다', () => { + expect(controller).toBeDefined(); + }); + + describe('getSuccessRate()는 ', () => { + const mockSuccessRateDto = { generation: 5 }; + const mockServiceResponse = { success_rate: 98.5 }; + + it('응답 성공률을 반환해야 한다', async () => { + mockSuccessRateService.getSuccessRate.mockResolvedValue(mockServiceResponse); + + const result = await controller.getSuccessRate(mockSuccessRateDto); + + expect(result).toEqual({ success_rate: 98.5 }); + expect(service.getSuccessRate).toHaveBeenCalledWith(mockSuccessRateDto); + expect(service.getSuccessRate).toHaveBeenCalledTimes(1); + }); + }); + + describe('getProjectSuccessRate()는 ', () => { + const mockRequestDto = { projectName: 'example-project' }; + const mockServiceResponse = { + projectName: 'example-project', + success_rate: 95.5, + }; + + it('해당 프로젝트의 응답 성공률을 반환해야 한다', async () => { + mockSuccessRateService.getProjectSuccessRate.mockResolvedValue(mockServiceResponse); + + const result = await controller.getProjectSuccessRate(mockRequestDto); + + expect(result).toEqual(mockServiceResponse); + expect(result).toHaveProperty('projectName', 'example-project'); + expect(result).toHaveProperty('success_rate', 95.5); + expect(service.getProjectSuccessRate).toHaveBeenCalledWith(mockRequestDto); + expect(service.getProjectSuccessRate).toHaveBeenCalledTimes(1); + }); + + it('서비스 호출 시, 에러가 발생하면 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockSuccessRateService.getProjectSuccessRate.mockRejectedValue(error); + + await expect(controller.getProjectSuccessRate(mockRequestDto)).rejects.toThrow(error); + + expect(service.getProjectSuccessRate).toHaveBeenCalledWith(mockRequestDto); + expect(service.getProjectSuccessRate).toHaveBeenCalledTimes(1); + }); + + it('성공률이 100%인 경우에도 정상적으로 반환해야 한다', async () => { + const perfectRateResponse = { + projectName: 'example-project', + success_rate: 100, + }; + mockSuccessRateService.getProjectSuccessRate.mockResolvedValue(perfectRateResponse); + + const result = await controller.getProjectSuccessRate(mockRequestDto); + + expect(result).toEqual(perfectRateResponse); + expect(result.success_rate).toBe(100); + expect(service.getProjectSuccessRate).toHaveBeenCalledWith(mockRequestDto); + expect(service.getProjectSuccessRate).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/backend/console-server/src/log/success-rate/success-rate.controller.ts b/backend/console-server/src/log/success-rate/success-rate.controller.ts new file mode 100644 index 00000000..b00e5f35 --- /dev/null +++ b/backend/console-server/src/log/success-rate/success-rate.controller.ts @@ -0,0 +1,42 @@ +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { SuccessRateService } from './success-rate.service'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { GetSuccessRateResponseDto } from './dto/get-success-rate-response.dto'; +import { GetSuccessRateDto } from './dto/get-success-rate.dto'; +import { GetProjectSuccessRateResponseDto } from './dto/get-project-success-rate-response.dto'; +import { GetProjectSuccessRateDto } from './dto/get-project-success-rate.dto'; + +@Controller('success-rate') +export class SuccessRateController { + constructor(private readonly successRateService: SuccessRateService) {} + + @Get('/success-rate') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '기수 내 응답 성공률', + description: '요청받은 기수의 기수 내 응답 성공률를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '기수 내 응답 성공률이 성공적으로 반환됨.', + type: GetSuccessRateResponseDto, + }) + async getSuccessRate(@Query() getSuccessRateDto: GetSuccessRateDto) { + return await this.successRateService.getSuccessRate(getSuccessRateDto); + } + + @Get('/success-rate/project') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '프로젝트 별 응답 성공률', + description: '요청받은 프로젝트의 응답 성공률을 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '프로젝트 별 응답 성공률이 성공적으로 반환됨.', + type: GetProjectSuccessRateResponseDto, + }) + async getProjectSuccessRate(@Query() getProjectSuccessRateDto: GetProjectSuccessRateDto) { + return await this.successRateService.getProjectSuccessRate(getProjectSuccessRateDto); + } +} diff --git a/backend/console-server/src/log/success-rate/success-rate.module.ts b/backend/console-server/src/log/success-rate/success-rate.module.ts new file mode 100644 index 00000000..12b548e5 --- /dev/null +++ b/backend/console-server/src/log/success-rate/success-rate.module.ts @@ -0,0 +1,15 @@ +import { SuccessRateController } from './success-rate.controller'; +import { SuccessRateService } from './success-rate.service'; +import { Module } from '@nestjs/common'; +import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { SuccessRateRepository } from './success-rate.repository'; +import { Clickhouse } from 'src/clickhouse/clickhouse'; + +@Module({ + imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], + controllers: [SuccessRateController], + providers: [SuccessRateService, SuccessRateRepository, Clickhouse], +}) +export class SuccessRateModule {} diff --git a/backend/console-server/src/log/success-rate/success-rate.repository.spec.ts b/backend/console-server/src/log/success-rate/success-rate.repository.spec.ts new file mode 100644 index 00000000..5c855bf1 --- /dev/null +++ b/backend/console-server/src/log/success-rate/success-rate.repository.spec.ts @@ -0,0 +1,93 @@ +import { SuccessRateRepository } from './success-rate.repository'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +describe('SuccessRateRepository 테스트', () => { + let repository: SuccessRateRepository; + let clickhouse: Clickhouse; + + const mockClickhouse = { + query: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SuccessRateRepository, + { + provide: Clickhouse, + useValue: mockClickhouse, + }, + ], + }).compile(); + + repository = module.get(SuccessRateRepository); + clickhouse = module.get(Clickhouse); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('레퍼지토리가 정의될 수 있어야 한다.', () => { + expect(repository).toBeDefined(); + }); + + describe('findSuccessRate()는 ', () => { + it('성공률을 올바르게 계산할 수 있어야 한다.', async () => { + const mockQueryResult = [{ is_error_rate: 1.5 }]; + (clickhouse.query as jest.Mock).mockResolvedValue(mockQueryResult); + + const result = await repository.findSuccessRate(); + + expect(result).toEqual({ success_rate: 98.5 }); + expect(clickhouse.query).toHaveBeenCalledWith( + 'SELECT (sum(is_error) / count(*)) * 100 as is_error_rate\nFROM http_log', + ); + }); + + it('에러율이 0%일 때 성공률 100%를 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue([{ is_error_rate: 0 }]); + + const result = await repository.findSuccessRate(); + + expect(result).toEqual({ success_rate: 100 }); + }); + }); + + describe('findSuccessRateByProject()는 ', () => { + const domain = 'example.com'; + + it('프로젝트별 성공률을 올바르게 계산할 수 있어야 한다', async () => { + const mockQueryResult = [{ is_error_rate: 1.5 }]; + (clickhouse.query as jest.Mock).mockResolvedValue(mockQueryResult); + + const result = await repository.findSuccessRateByProject(domain); + + const expectedQuery = `SELECT (sum(is_error) / count(*)) * 100 as is_error_rate +FROM (SELECT is_error, timestamp +FROM http_log WHERE host = {host:String} ORDER BY timestamp DESC LIMIT 1000) as subquery`; + + expect(result).toEqual({ success_rate: 98.5 }); + expect(clickhouse.query).toHaveBeenCalledWith(expectedQuery, { host: domain }); + }); + + it('에러율이 0%일 때 성공률 100%를 반환해야 한다', async () => { + (clickhouse.query as jest.Mock).mockResolvedValue([{ is_error_rate: 0 }]); + + const result = await repository.findSuccessRateByProject(domain); + + expect(result).toEqual({ success_rate: 100 }); + }); + + it('쿼리 에러 발생시 예외를 throw 해야 한다', async () => { + const error = new Error('Clickhouse connection error'); + (clickhouse.query as jest.Mock).mockRejectedValue(error); + + await expect(repository.findSuccessRateByProject(domain)).rejects.toThrow( + 'Clickhouse connection error', + ); + }); + }); +}); diff --git a/backend/console-server/src/log/success-rate/success-rate.repository.ts b/backend/console-server/src/log/success-rate/success-rate.repository.ts new file mode 100644 index 00000000..2a116956 --- /dev/null +++ b/backend/console-server/src/log/success-rate/success-rate.repository.ts @@ -0,0 +1,58 @@ +import { Clickhouse } from '../../clickhouse/clickhouse'; +import { TimeSeriesQueryBuilder } from '../../clickhouse/query-builder/time-series.query-builder'; +import type { ErrorRateMetric } from './metric/error-rate.metric'; +import { plainToInstance } from 'class-transformer'; +import { SuccessRateMetric } from './metric/success-rate.metric'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class SuccessRateRepository { + constructor(@Inject(Clickhouse) private readonly clickhouse: Clickhouse) {} + + async findSuccessRate() { + const { query } = new TimeSeriesQueryBuilder() + .metrics([ + { + name: 'is_error', + aggregation: 'rate', + }, + ]) + .from('http_log') + .build(); + + const [result] = await this.clickhouse.query(query); + + return plainToInstance(SuccessRateMetric, { + success_rate: 100 - result.is_error_rate, + }); + } + + async findSuccessRateByProject(domain: string) { + const subQueryBuilder = new TimeSeriesQueryBuilder() + .metrics([{ name: 'is_error' }, { name: 'timestamp' }]) + .from('http_log') + .filter({ host: domain }) + .orderBy(['timestamp'], true) + .limit(1000) + .build(); + + const mainQueryBuilder = new TimeSeriesQueryBuilder() + .metrics([ + { + name: 'is_error', + aggregation: 'rate', + }, + ]) + .from(`(${subQueryBuilder.query}) as subquery`) + .build(); + + const [result] = await this.clickhouse.query( + mainQueryBuilder.query, + subQueryBuilder.params, + ); + + return plainToInstance(SuccessRateMetric, { + success_rate: 100 - result.is_error_rate, + }); + } +} diff --git a/backend/console-server/src/log/success-rate/success-rate.service.spec.ts b/backend/console-server/src/log/success-rate/success-rate.service.spec.ts new file mode 100644 index 00000000..5de26409 --- /dev/null +++ b/backend/console-server/src/log/success-rate/success-rate.service.spec.ts @@ -0,0 +1,142 @@ +import { SuccessRateService } from './success-rate.service'; +import { SuccessRateRepository } from './success-rate.repository'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { GetProjectSuccessRateResponseDto } from './dto/get-project-success-rate-response.dto'; +import { NotFoundException } from '@nestjs/common'; + +describe('SuccessRateService 테스트', () => { + let service: SuccessRateService; + let repository: SuccessRateRepository; + + const mockSuccessRateRepository = { + findSuccessRate: jest.fn(), + findSuccessRateByProject: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SuccessRateService, + { + provide: SuccessRateRepository, + useValue: mockSuccessRateRepository, + }, + { + provide: getRepositoryToken(Project), + useValue: { findOne: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(SuccessRateService); + repository = module.get(SuccessRateRepository); + + jest.clearAllMocks(); + }); + + it('서비스가 정의될 수 있어야 한다.', () => { + expect(service).toBeDefined(); + }); + + describe('responseSuccessRate()는 ', () => { + it('응답 성공률을 반환할 수 있어야 한다.', async () => { + const mockSuccessRateDto = { generation: 9 }; + const mockRepositoryResponse = { success_rate: 98.5 }; + mockSuccessRateRepository.findSuccessRate.mockResolvedValue(mockRepositoryResponse); + const expectedResult = { success_rate: 98.5 }; + + const result = await service.getSuccessRate(mockSuccessRateDto); + + expect(result).toEqual(expectedResult); + expect(repository.findSuccessRate).toHaveBeenCalled(); + }); + }); + + describe('getProjectSuccessRate()는 ', () => { + const mockRequestDto = { projectName: 'example-project' }; + const mockProject = { + name: 'example-project', + domain: 'example.com', + }; + const mockSuccessRate = { success_rate: 95.5 }; + + it('projectName을 이용해 도메인을 조회한 후 응답 성공률을 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockSuccessRateRepository.findSuccessRateByProject = jest + .fn() + .mockResolvedValue(mockSuccessRate); + + const result = await service.getProjectSuccessRate(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockSuccessRateRepository.findSuccessRateByProject).toHaveBeenCalledWith( + mockProject.domain, + ); + expect(result).toEqual( + expect.objectContaining({ + success_rate: mockSuccessRate.success_rate, + }), + ); + }); + + it('응답이 GetSuccessRateByProjectResponseDTO 형태로 변환되어야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockSuccessRateRepository.findSuccessRateByProject.mockResolvedValue(mockSuccessRate); + + const result = await service.getProjectSuccessRate(mockRequestDto); + + expect(result).toBeInstanceOf(GetProjectSuccessRateResponseDto); + expect(Object.keys(result)).toContain('projectName'); + expect(Object.keys(result)).toContain('success_rate'); + expect(Object.keys(result).length).toBe(2); + expect(result.projectName).toBe(mockRequestDto.projectName); + expect(result.success_rate).toBe(mockSuccessRate.success_rate); + }); + + it('존재하지 않는 프로젝트명을 받은 경우, NotFoundException을 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect(service.getProjectSuccessRate(mockRequestDto)).rejects.toThrow( + new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockSuccessRateRepository.findSuccessRateByProject).not.toHaveBeenCalled(); + }); + + it('레포지토리 호출 시, 에러가 발생하면, 에러를 발생시켜야한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockSuccessRateRepository.findSuccessRateByProject = jest + .fn() + .mockRejectedValue(new Error('Database error')); + + await expect(service.getProjectSuccessRate(mockRequestDto)).rejects.toThrow( + 'Database error', + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockSuccessRateRepository.findSuccessRateByProject).toHaveBeenCalledWith( + mockProject.domain, + ); + }); + }); +}); diff --git a/backend/console-server/src/log/success-rate/success-rate.service.ts b/backend/console-server/src/log/success-rate/success-rate.service.ts new file mode 100644 index 00000000..21855239 --- /dev/null +++ b/backend/console-server/src/log/success-rate/success-rate.service.ts @@ -0,0 +1,43 @@ +import type { GetSuccessRateDto } from './dto/get-success-rate.dto'; +import { plainToInstance } from 'class-transformer'; +import { GetSuccessRateResponseDto } from './dto/get-success-rate-response.dto'; +import type { GetProjectSuccessRateDto } from './dto/get-project-success-rate.dto'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { GetProjectSuccessRateResponseDto } from './dto/get-project-success-rate-response.dto'; +import type { Repository } from 'typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { SuccessRateRepository } from './success-rate.repository'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class SuccessRateService { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + private readonly successRateRepository: SuccessRateRepository, + ) {} + + async getSuccessRate(_getSuccessRateDto: GetSuccessRateDto) { + const result = await this.successRateRepository.findSuccessRate(); + + return plainToInstance(GetSuccessRateResponseDto, result); + } + + async getProjectSuccessRate(getProjectSuccessRateDto: GetProjectSuccessRateDto) { + const { projectName } = getProjectSuccessRateDto; + + const project = await this.projectRepository.findOne({ + where: { name: projectName }, + select: ['domain'], + }); + + if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); + + const result = await this.successRateRepository.findSuccessRateByProject(project.domain); + + return plainToInstance(GetProjectSuccessRateResponseDto, { + projectName, + ...result, + }); + } +} diff --git a/backend/console-server/src/log/dto/get-traffic-by-generation-response.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-by-generation-response.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-by-generation-response.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-by-generation-response.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-by-generation.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-by-generation.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-by-generation.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-by-generation.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-by-project-response.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-by-project-response.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-by-project-response.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-by-project-response.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-by-project.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-by-project.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-by-project.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-by-project.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-daily-difference-response.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-daily-difference-response.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-daily-difference-response.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-daily-difference-response.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-daily-difference.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-daily-difference.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-daily-difference.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-daily-difference.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-rank-response.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-rank-response.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-rank-response.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-rank-response.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-top5-chart-response.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-top5-chart-response.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-top5-chart-response.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-top5-chart-response.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-top5-chart.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-top5-chart.dto.ts similarity index 100% rename from backend/console-server/src/log/dto/get-traffic-top5-chart.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-top5-chart.dto.ts diff --git a/backend/console-server/src/log/dto/get-traffic-rank.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-top5.dto.ts similarity index 91% rename from backend/console-server/src/log/dto/get-traffic-rank.dto.ts rename to backend/console-server/src/log/traffic/dto/get-traffic-top5.dto.ts index 3f0409c9..644eb9f9 100644 --- a/backend/console-server/src/log/dto/get-traffic-rank.dto.ts +++ b/backend/console-server/src/log/traffic/dto/get-traffic-top5.dto.ts @@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; import { IsNotEmpty, IsNumber } from 'class-validator'; -export class GetTrafficRankDto { +export class GetTrafficTop5Dto { @ApiProperty({ example: 9, description: '기수', diff --git a/backend/console-server/src/log/metric/trafficChart.metric.ts b/backend/console-server/src/log/traffic/metric/traffic-chart.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/trafficChart.metric.ts rename to backend/console-server/src/log/traffic/metric/traffic-chart.metric.ts diff --git a/backend/console-server/src/log/metric/traffic-count-by-timeunit.metric.ts b/backend/console-server/src/log/traffic/metric/traffic-count-by-timeunit.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/traffic-count-by-timeunit.metric.ts rename to backend/console-server/src/log/traffic/metric/traffic-count-by-timeunit.metric.ts diff --git a/backend/console-server/src/log/metric/traffic-count.metric.ts b/backend/console-server/src/log/traffic/metric/traffic-count.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/traffic-count.metric.ts rename to backend/console-server/src/log/traffic/metric/traffic-count.metric.ts diff --git a/backend/console-server/src/log/metric/traffic-rank-top5.metric.ts b/backend/console-server/src/log/traffic/metric/traffic-rank-top5.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/traffic-rank-top5.metric.ts rename to backend/console-server/src/log/traffic/metric/traffic-rank-top5.metric.ts diff --git a/backend/console-server/src/log/metric/traffic-rank.metric.ts b/backend/console-server/src/log/traffic/metric/traffic-rank.metric.ts similarity index 100% rename from backend/console-server/src/log/metric/traffic-rank.metric.ts rename to backend/console-server/src/log/traffic/metric/traffic-rank.metric.ts diff --git a/backend/console-server/src/log/traffic/traffic.controller.spec.ts b/backend/console-server/src/log/traffic/traffic.controller.spec.ts new file mode 100644 index 00000000..54b4b211 --- /dev/null +++ b/backend/console-server/src/log/traffic/traffic.controller.spec.ts @@ -0,0 +1,181 @@ +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { HttpStatus } from '@nestjs/common'; +import { plainToInstance } from 'class-transformer'; +import { GetTrafficTop5Dto } from './dto/get-traffic-top5.dto'; +import { TrafficController } from './traffic.controller'; +import { TrafficService } from './traffic.service'; + +interface TrafficRankResponseType { + status: number; + data: Array<{ host: string; count: number }>; +} + +describe('TrafficController 테스트', () => { + let controller: TrafficController; + let service: TrafficService; + + const mockTrafficService = { + getTrafficTop5: jest.fn(), + getTrafficByGeneration: jest.fn(), + getTrafficByProject: jest.fn(), + getTrafficDailyDifferenceByGeneration: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [TrafficController], + providers: [ + { + provide: TrafficService, + useValue: mockTrafficService, + }, + ], + }).compile(); + + controller = module.get(TrafficController); + service = module.get(TrafficService); + + jest.clearAllMocks(); + }); + + it('컨트롤러가 정의되어 있어야 한다', () => { + expect(controller).toBeDefined(); + }); + + describe('getTrafficDailyDifferenceByGeneration()는 ', () => { + const mockRequestDto = { generation: 9 }; + const mockResponseDto = { + traffic_daily_difference: '+9100', + }; + + it('전일 대비 트래픽 차이를 리턴해야 한다', async () => { + mockTrafficService.getTrafficDailyDifferenceByGeneration.mockResolvedValue( + mockResponseDto, + ); + + const result = await controller.getTrafficDailyDifferenceByGeneration(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(result).toHaveProperty('traffic_daily_difference'); + expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledWith( + mockRequestDto, + ); + expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledTimes(1); + }); + + it('에러 발생 시, 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockTrafficService.getTrafficDailyDifferenceByGeneration.mockRejectedValue(error); + + await expect( + controller.getTrafficDailyDifferenceByGeneration(mockRequestDto), + ).rejects.toThrow(error); + + expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledWith( + mockRequestDto, + ); + expect(service.getTrafficDailyDifferenceByGeneration).toHaveBeenCalledTimes(1); + }); + }); + + describe('getTrafficByProject()는', () => { + const mockRequestDto = { projectName: 'example-project', timeUnit: 'month' }; + const mockResponseDto = { + projectName: 'example-project', + timeUnit: 'month', + trafficData: [ + { timestamp: '2024-11-01', count: 14 }, + { timestamp: '2024-10-01', count: 10 }, + ], + }; + it('프로젝트의 시간 단위별 트래픽 데이터를 반환해야 한다', async () => { + mockTrafficService.getTrafficByProject.mockResolvedValue(mockResponseDto); + + const result = await controller.getTrafficByProject(mockRequestDto); + + expect(result).toEqual(mockResponseDto); + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result).toHaveProperty('timeUnit', mockRequestDto.timeUnit); + expect(result.trafficData).toHaveLength(2); + expect(result.trafficData[0]).toHaveProperty('timestamp', '2024-11-01'); + expect(result.trafficData[0]).toHaveProperty('count', 14); + expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); + }); + + it('서비스 에러 시 예외를 throw 해야 한다', async () => { + const error = new Error('Database error'); + mockTrafficService.getTrafficByProject.mockRejectedValue(error); + + await expect(controller.getTrafficByProject(mockRequestDto)).rejects.toThrow(error); + + expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); + }); + + it('빈 트래픽 데이터를 반환해야 한다 (No Data)', async () => { + const emptyResponseDto = { + projectName: 'example-project', + timeUnit: 'month', + trafficData: [], + }; + mockTrafficService.getTrafficByProject.mockResolvedValue(emptyResponseDto); + + const result = await controller.getTrafficByProject(mockRequestDto); + + expect(result).toEqual(emptyResponseDto); + expect(result).toHaveProperty('projectName', mockRequestDto.projectName); + expect(result).toHaveProperty('timeUnit', mockRequestDto.timeUnit); + expect(result.trafficData).toHaveLength(0); + expect(service.getTrafficByProject).toHaveBeenCalledWith(mockRequestDto); + expect(service.getTrafficByProject).toHaveBeenCalledTimes(1); + }); + }); + + describe('trafficByGeneration()는 ', () => { + it('기수별 트래픽 총량을 올바르게 반환해야 한다', async () => { + const mockTrafficByGenerationDto = { generation: 9 }; + const mockResponse = { count: 1000 }; + + mockTrafficService.getTrafficByGeneration.mockResolvedValue(mockResponse); + + const result = await controller.getTrafficByGeneration(mockTrafficByGenerationDto); + + expect(result).toEqual(mockResponse); + expect(result).toHaveProperty('count', 1000); + expect(service.getTrafficByGeneration).toHaveBeenCalledWith(mockTrafficByGenerationDto); + expect(service.getTrafficByGeneration).toHaveBeenCalledTimes(1); + }); + }); + + describe('trafficRank()는 ', () => { + const mockResult: TrafficRankResponseType = { + status: HttpStatus.OK, + data: [ + { host: 'api1.example.com', count: 1000 }, + { host: 'api2.example.com', count: 800 }, + { host: 'api3.example.com', count: 600 }, + { host: 'api4.example.com', count: 400 }, + { host: 'api5.example.com', count: 200 }, + ], + }; + + it('TOP 5 트래픽 순위를 ProjectResponseDto 형식으로 반환해야 한다', async () => { + mockTrafficService.getTrafficTop5.mockResolvedValue(mockResult); + + const getTrafficTop5Dto = plainToInstance(GetTrafficTop5Dto, { generation: 1 }); + const result = (await controller.getTrafficTop5( + getTrafficTop5Dto, + )) as unknown as TrafficRankResponseType; + + expect(result).toEqual(mockResult); + expect(result).toHaveProperty('status', HttpStatus.OK); + expect(result.data).toHaveLength(5); + expect(mockTrafficService.getTrafficTop5).toHaveBeenCalledTimes(1); + + const sortedData = [...result.data].sort((a, b) => b.count - a.count); + expect(result.data).toEqual(sortedData); + }); + }); +}); diff --git a/backend/console-server/src/log/traffic/traffic.controller.ts b/backend/console-server/src/log/traffic/traffic.controller.ts new file mode 100644 index 00000000..b1db0e92 --- /dev/null +++ b/backend/console-server/src/log/traffic/traffic.controller.ts @@ -0,0 +1,97 @@ +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { GetTrafficRankResponseDto } from './dto/get-traffic-rank-response.dto'; +import { GetTrafficTop5Dto } from './dto/get-traffic-top5.dto'; +import { GetTrafficByProjectResponseDto } from './dto/get-traffic-by-project-response.dto'; +import { GetTrafficByProjectDto } from './dto/get-traffic-by-project.dto'; +import { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generation-response.dto'; +import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; +import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; +import { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; +import { GetTrafficTop5ChartResponseDto } from './dto/get-traffic-top5-chart-response.dto'; +import { GetTrafficTop5ChartDto } from './dto/get-traffic-top5-chart.dto'; +import { TrafficService } from './traffic.service'; + +@Controller('traffic') +export class TrafficController { + constructor(private readonly trafficService: TrafficService) {} + + @Get('/rank') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '기수 내 트래픽 랭킹 TOP 5', + description: '요청받은 기수의 트래픽 랭킹 TOP 5를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '트래픽 랭킹 TOP 5가 정상적으로 반환됨.', + type: GetTrafficRankResponseDto, + }) + async getTrafficTop5(@Query() getTrafficTop5Dto: GetTrafficTop5Dto) { + return await this.trafficService.getTrafficTop5(getTrafficTop5Dto); + } + + @Get('/project') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '프로젝트 별 트래픽 조회', + description: '프로젝트 이름과 시간 단위로 특정 프로젝트의 트래픽 데이터를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '특정 프로젝트의 트래픽 데이터가 반환됨.', + type: GetTrafficByProjectResponseDto, + }) + async getTrafficByProject(@Query() getTrafficByProjectDto: GetTrafficByProjectDto) { + return await this.trafficService.getTrafficByProject(getTrafficByProjectDto); + } + + @Get('') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '기수 내 총 트래픽', + description: ' 요청받은 기수의 기수 내 총 트래픽를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '기수 내 총 트래픽이 정상적으로 반환됨.', + type: GetTrafficByGenerationResponseDto, + }) + async getTrafficByGeneration(@Query() getTrafficByGenerationDto: GetTrafficByGenerationDto) { + return await this.trafficService.getTrafficByGeneration(getTrafficByGenerationDto); + } + + @Get('/daily-difference') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '기수 별 프로젝트 전일 대비 트래픽', + description: '요청받은 기수의 프로젝트 전일 대비 트래픽을 반환합니다.', + }) + @ApiResponse({ + status: 200, + description: '기수별 프로젝트 전일 대비 트래픽이 정상적으로 반환됨', + type: GetTrafficDailyDifferenceResponseDto, + }) + async getTrafficDailyDifferenceByGeneration( + @Query() getTrafficDailyDifferenceDto: GetTrafficDailyDifferenceDto, + ) { + return await this.trafficService.getTrafficDailyDifferenceByGeneration( + getTrafficDailyDifferenceDto, + ); + } + + @Get('/top5/line-chart') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '프로젝트 트래픽 TOP 5에 대한 트래픽 데이터 조회', + description: '프로젝트별 작일 데이터 전체 타임스탬프를 반환', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '프로젝트별 작일 데이터 전체 타임스탬프가 정상적으로 반환됨', + type: GetTrafficTop5ChartResponseDto, + }) + async getTrafficTop5Chart(@Query() getTrafficTop5ChartDto: GetTrafficTop5ChartDto) { + return await this.trafficService.getTrafficTop5Chart(getTrafficTop5ChartDto); + } +} diff --git a/backend/console-server/src/log/traffic/traffic.module.ts b/backend/console-server/src/log/traffic/traffic.module.ts new file mode 100644 index 00000000..d2feaaa6 --- /dev/null +++ b/backend/console-server/src/log/traffic/traffic.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TrafficService } from './traffic.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { TrafficRepository } from './traffic.repository'; +import { TrafficController } from './traffic.controller'; +import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; +import { Clickhouse } from '../../clickhouse/clickhouse'; + +@Module({ + imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], + controllers: [TrafficController], + providers: [TrafficService, TrafficRepository, Clickhouse], +}) +export class TrafficModule {} diff --git a/backend/console-server/src/log/traffic/traffic.repository.spec.ts b/backend/console-server/src/log/traffic/traffic.repository.spec.ts new file mode 100644 index 00000000..843bbd28 --- /dev/null +++ b/backend/console-server/src/log/traffic/traffic.repository.spec.ts @@ -0,0 +1,147 @@ +import { TrafficRepository } from './traffic.repository'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; + +describe('TrafficRepository 테스트', () => { + let repository: TrafficRepository; + let clickhouse: Clickhouse; + + const mockClickhouse = { + query: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrafficRepository, + { + provide: Clickhouse, + useValue: mockClickhouse, + }, + ], + }).compile(); + + repository = module.get(TrafficRepository); + clickhouse = module.get(Clickhouse); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('레퍼지토리가 정의될 수 있어야 한다.', () => { + expect(repository).toBeDefined(); + }); + describe('findCountByHost()는 ', () => { + it('호스트별 요청 수를 내림차순으로 정렬하여 반환해야 한다.', async () => { + const mockResult = [ + { host: 'api.example.com', count: 1000 }, + { host: 'web.example.com', count: 500 }, + ]; + mockClickhouse.query.mockResolvedValue(mockResult); + + const result = await repository.findTop5CountByHost(); + + expect(result).toEqual(mockResult); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching(/GROUP BY.*host.*ORDER BY.*DESC/), + expect.any(Object), + ); + }); + }); + + describe('findTrafficByGeneration()는 ', () => { + it('전체 트래픽 수를 반환해야 한다.', async () => { + const mockResult = { count: 5000 }; + + mockClickhouse.query.mockResolvedValue([mockResult]); + const result = await repository.findTrafficByGeneration(); + + expect(result).toEqual(mockResult); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching(/SELECT.*count\(\).*as count/s), + expect.any(Object), + ); + }); + }); + + describe('getTrafficByProject()는', () => { + const domain = 'example.com'; + const timeUnit = 'Hour'; + + const mockTrafficData = [ + { timestamp: '2024-11-18 10:00:00', count: 150 }, + { timestamp: '2024-11-18 11:00:00', count: 120 }, + { timestamp: '2024-11-18 12:00:00', count: 180 }, + ]; + + it('올바른 도메인과 시간 단위를 기준으로 트래픽 데이터를 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue(mockTrafficData); + + const result = await repository.findTrafficByProject(domain, timeUnit); + + expect(result).toEqual(mockTrafficData); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.stringMatching( + /SELECT\s+count\(\)\s+as\s+count,\s+toStartOfHour\(timestamp\)\s+as\s+timestamp\s+FROM\s+http_log\s+WHERE\s+host\s+=\s+\{host:String}\s+GROUP\s+BY\s+timestamp\s+ORDER\s+BY\s+timestamp/s, + ), + expect.objectContaining({ host: domain }), + ); + }); + + it('트래픽 데이터가 없을 경우 빈 배열을 반환해야 한다.', async () => { + mockClickhouse.query.mockResolvedValue([]); + + const result = await repository.findTrafficByProject(domain, timeUnit); + + expect(result).toEqual([]); + expect(clickhouse.query).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ host: domain }), + ); + }); + + it('Clickhouse 호출 중 에러가 발생하면 예외를 throw 해야 한다.', async () => { + const error = new Error('Clickhouse query failed'); + mockClickhouse.query.mockRejectedValue(error); + + await expect(repository.findTrafficByProject(domain, timeUnit)).rejects.toThrow( + 'Clickhouse query failed', + ); + + expect(clickhouse.query).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ host: domain }), + ); + }); + }); + + describe('findTrafficForTimeRange()는 ', () => { + const mockDate = new Date('2024-03-20T15:00:00Z'); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockDate); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('특정 기간의 트래픽을 리턴할 수 있어야 한다.', async () => { + const mockTraffic = [{ count: 500 }]; + const timeRange = { + start: new Date('2024-01-02T00:00:00Z'), + end: new Date('2024-01-02T23:59:59Z'), + }; + + mockClickhouse.query.mockResolvedValue(mockTraffic); + + const result = await repository.findTrafficForTimeRange(timeRange.start, timeRange.end); + + expect(result).toEqual(mockTraffic); + expect(clickhouse.query).toHaveBeenCalledWith(expect.any(String), expect.any(Object)); + }); + }); +}); diff --git a/backend/console-server/src/log/traffic/traffic.repository.ts b/backend/console-server/src/log/traffic/traffic.repository.ts new file mode 100644 index 00000000..545bc32a --- /dev/null +++ b/backend/console-server/src/log/traffic/traffic.repository.ts @@ -0,0 +1,126 @@ +import { TrafficChartMetric } from './metric/traffic-chart.metric'; +import { plainToInstance } from 'class-transformer'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import { TimeSeriesQueryBuilder } from '../../clickhouse/query-builder/time-series.query-builder'; +import { TrafficRankMetric } from './metric/traffic-rank.metric'; +import { TrafficRankTop5Metric } from './metric/traffic-rank-top5.metric'; +import { TrafficCountMetric } from './metric/traffic-count.metric'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class TrafficRepository { + constructor(private readonly clickhouse: Clickhouse) {} + + async findTop5CountByHost() { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([ + { + name: 'host', + }, + { + name: '*', + aggregation: 'count', + }, + ]) + .from('http_log') + .groupBy(['host']) + .orderBy(['count'], true) + .limit(5) + .build(); + + const results = await this.clickhouse.query(query, params); + + return plainToInstance( + TrafficRankTop5Metric, + results.map((result) => plainToInstance(TrafficRankMetric, result)), + ); + } + + async findTrafficByGeneration() { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([ + { + name: '*', + aggregation: 'count', + }, + ]) + .from('http_log') + .build(); + + const [result] = await this.clickhouse.query(query, params); + + return plainToInstance(TrafficCountMetric, result); + } + + async findTrafficForTimeRange(start: Date, end: Date) { + const queryBuilder = new TimeSeriesQueryBuilder() + .metrics([{ name: '*', aggregation: 'count' }]) + .from('http_log') + .timeBetween(start, end) + .build(); + + return this.clickhouse.query<{ count: number }>(queryBuilder.query, queryBuilder.params); + } + + async findTrafficByProject(domain: string, timeUnit: string) { + const { query, params } = new TimeSeriesQueryBuilder() + .metrics([ + { name: '*', aggregation: 'count' }, + { name: `toStartOf${timeUnit}(timestamp) as timestamp` }, + ]) + .from('http_log') + .filter({ host: domain }) + .groupBy(['timestamp']) + .orderBy(['timestamp'], false) + .build(); + + const results = await this.clickhouse.query(query, params); + + return results.map((result) => plainToInstance(TrafficCountMetric, result)); + } + async findTrafficTop5Chart() { + const now = new Date(); + const today = new Date(now.setHours(0, 0, 0, 0)); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + + const query = `WITH top_hosts AS ( + SELECT host + FROM http_log + WHERE timestamp >= {startTime: DateTime64(3)} + AND timestamp < {endTime: DateTime64(3)} + GROUP BY host + ORDER BY count() DESC + LIMIT 5 + ) + SELECT + host, + groupArray( + ( + toDateTime64(toStartOfInterval(timestamp, INTERVAL 1 MINUTE), 0), + requests_count + ) + ) as traffic + FROM ( + SELECT + host, + toDateTime64(toStartOfInterval(timestamp, INTERVAL 1 MINUTE), 0) as timestamp, + count() as requests_count + FROM http_log + WHERE timestamp >= {startTime: DateTime64(3)} + AND timestamp < {endTime: DateTime64(3)} + AND host IN (SELECT host FROM top_hosts) + GROUP BY + host, + timestamp + ORDER BY + timestamp + ) + GROUP BY host;`; + const params = { startTime: yesterday, endTime: today }; + const results = await this.clickhouse.query(query, params); + + return results.map((result) => { + return plainToInstance(TrafficChartMetric, result); + }); + } +} diff --git a/backend/console-server/src/log/traffic/traffic.service.spec.ts b/backend/console-server/src/log/traffic/traffic.service.spec.ts new file mode 100644 index 00000000..e54cacbe --- /dev/null +++ b/backend/console-server/src/log/traffic/traffic.service.spec.ts @@ -0,0 +1,285 @@ +import { TrafficService } from './traffic.service'; +import { TrafficRepository } from './traffic.repository'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { plainToInstance } from 'class-transformer'; +import { GetTrafficTop5Dto } from './dto/get-traffic-top5.dto'; +import type { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generation-response.dto'; +import { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; +import { NotFoundException } from '@nestjs/common'; +import type { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; +import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; + +describe('TrafficService 테스트', () => { + let service: TrafficService; + let repository: TrafficRepository; + + const mockTrafficRepository = { + findTop5CountByHost: jest.fn(), + findTrafficByGeneration: jest.fn(), + findTrafficByProject: jest.fn(), + findTrafficDailyDifferenceByGeneration: jest.fn(), + findTrafficForTimeRange: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TrafficService, + { + provide: TrafficRepository, + useValue: mockTrafficRepository, + }, + { + provide: getRepositoryToken(Project), + useValue: { findOne: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(TrafficService); + repository = module.get(TrafficRepository); + + jest.clearAllMocks(); + }); + + it('서비스가 정의될 수 있어야 한다.', () => { + expect(service).toBeDefined(); + }); + + describe('trafficRank()는 ', () => { + it('top 5 traffic rank를 리턴할 수 있어야 한다.', async () => { + const mockRanks = [ + { host: 'api1.example.com', count: 1000 }, + { host: 'api2.example.com', count: 800 }, + { host: 'api3.example.com', count: 600 }, + { host: 'api4.example.com', count: 400 }, + { host: 'api5.example.com', count: 200 }, + ]; + mockTrafficRepository.findTop5CountByHost.mockResolvedValue(mockRanks); + + const result = await service.getTrafficTop5( + plainToInstance(GetTrafficTop5Dto, { generation: 9 }), + ); + + expect(result).toHaveLength(5); + expect(result).toEqual(mockRanks.slice(0, 5)); + expect(repository.findTop5CountByHost).toHaveBeenCalled(); + }); + + it('5개 이하의 결과에 대해서 올바르게 처리할 수 있어야 한다.', async () => { + const mockRanks = [ + { host: 'api1.example.com', count: 1000 }, + { host: 'api2.example.com', count: 800 }, + ]; + mockTrafficRepository.findTop5CountByHost.mockResolvedValue(mockRanks); + + const result = await service.getTrafficTop5( + plainToInstance(GetTrafficTop5Dto, { generation: 9 }), + ); + + expect(result).toHaveLength(2); + expect(result).toEqual(mockRanks); + }); + }); + + describe('trafficByGeneration()는 ', () => { + it('기수별 트래픽의 총합을 올바르게 반환할 수 있어야 한다.', async () => { + const mockRepositoryResponse = { count: 1000 }; + const expectedResponse: GetTrafficByGenerationResponseDto = { + count: 1000, + }; + mockTrafficRepository.findTrafficByGeneration.mockResolvedValue(mockRepositoryResponse); + + const result = await service.getTrafficByGeneration( + plainToInstance(GetTrafficByGenerationDto, { generation: 9 }), + ); + + expect(result).toEqual(expectedResponse); + expect(mockTrafficRepository.findTrafficByGeneration).toHaveBeenCalledTimes(1); + expect(mockTrafficRepository.findTrafficByGeneration).toHaveBeenCalled(); + }); + }); + + describe('getTrafficByProject()는', () => { + const mockRequestDto = { projectName: 'example-project', timeUnit: 'month' }; + const mockProject = { + name: 'example-project', + domain: 'example.com', + }; + const mockTrafficData = [ + { timestamp: '2024-11-01', count: 14 }, + { timestamp: '2024-10-01', count: 10 }, + ]; + const mockResponseDto = { + projectName: 'example-project', + timeUnit: 'month', + trafficData: mockTrafficData, + }; + + it('프로젝트명을 기준으로 도메인을 조회한 후 트래픽 데이터를 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockTrafficRepository.findTrafficByProject = jest + .fn() + .mockResolvedValue(mockTrafficData); + + const result = await service.getTrafficByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockTrafficRepository.findTrafficByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.timeUnit, + ); + expect(result).toEqual(mockResponseDto); + }); + + it('존재하지 않는 프로젝트명을 조회할 경우 NotFoundException을 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(null); + + await expect(service.getTrafficByProject(mockRequestDto)).rejects.toThrow( + new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockTrafficRepository.findTrafficByProject).not.toHaveBeenCalled(); + }); + + it('로그 레포지토리 호출 중 에러가 발생할 경우 예외를 던져야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockTrafficRepository.findTrafficByProject = jest + .fn() + .mockRejectedValue(new Error('Database error')); + + await expect(service.getTrafficByProject(mockRequestDto)).rejects.toThrow( + 'Database error', + ); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockTrafficRepository.findTrafficByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.timeUnit, + ); + }); + + it('트래픽 데이터가 없을 경우 빈 배열을 반환해야 한다', async () => { + const projectRepository = service['projectRepository']; + projectRepository.findOne = jest.fn().mockResolvedValue(mockProject); + + mockTrafficRepository.findTrafficByProject = jest.fn().mockResolvedValue([]); + + const result = await service.getTrafficByProject(mockRequestDto); + + expect(projectRepository.findOne).toHaveBeenCalledWith({ + where: { name: mockRequestDto.projectName }, + select: ['domain'], + }); + expect(mockTrafficRepository.findTrafficByProject).toHaveBeenCalledWith( + mockProject.domain, + mockRequestDto.timeUnit, + ); + expect(result).toEqual({ + projectName: mockRequestDto.projectName, + timeUnit: mockRequestDto.timeUnit, + trafficData: [], + }); + }); + }); + + describe('getTrafficDailyDifferenceByGeneration()는 ', () => { + const mockRequestDto: GetTrafficDailyDifferenceDto = { generation: 9 }; + let mockDate: Date; + + beforeEach(() => { + mockDate = new Date('2024-03-20T15:00:00Z'); + jest.useFakeTimers(); + jest.setSystemTime(mockDate); + (mockTrafficRepository.findTrafficForTimeRange as jest.Mock).mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('전일 대비 총 트래픽 증감량을 리턴해야 한다', async () => { + const todayTraffic = [{ count: 10000 }]; + const yesterdayTraffic = [{ count: 900 }]; + + (mockTrafficRepository.findTrafficForTimeRange as jest.Mock) + .mockResolvedValueOnce(todayTraffic) + .mockResolvedValueOnce(yesterdayTraffic); + + const result = await service.getTrafficDailyDifferenceByGeneration(mockRequestDto); + + expect(result).toBeInstanceOf(GetTrafficDailyDifferenceResponseDto); + expect(result.traffic_daily_difference).toBe('+9100'); + expect(mockTrafficRepository.findTrafficForTimeRange).toHaveBeenCalledTimes(2); + }); + + it('트래픽의 차이가 0인 경우에도 올바르게 처리해야 한다', async () => { + const sameTraffic = [{ count: 500 }]; + + (mockTrafficRepository.findTrafficForTimeRange as jest.Mock) + .mockResolvedValueOnce(sameTraffic) + .mockResolvedValueOnce(sameTraffic); + + const result = await service.getTrafficDailyDifferenceByGeneration(mockRequestDto); + + expect(result).toBeInstanceOf(GetTrafficDailyDifferenceResponseDto); + expect(result.traffic_daily_difference).toBe('0'); + }); + + it('레포지토리 호출 시, 발생하는 에러를 throw 해야 한다', async () => { + (mockTrafficRepository.findTrafficForTimeRange as jest.Mock).mockRejectedValue( + new Error('Database error'), + ); + + await expect( + service.getTrafficDailyDifferenceByGeneration(mockRequestDto), + ).rejects.toThrow('Database error'); + }); + + it('시간 범위가 올바르게 계산되어야 한다', async () => { + const mockTraffic = [{ count: 100 }]; + (mockTrafficRepository.findTrafficForTimeRange as jest.Mock).mockResolvedValue( + mockTraffic, + ); + + const todayStart = new Date(mockDate); + todayStart.setHours(0, 0, 0, 0); + + const yesterdayStart = new Date(mockDate); + yesterdayStart.setDate(yesterdayStart.getDate() - 1); + yesterdayStart.setHours(0, 0, 0, 0); + + const yesterdayEnd = new Date(todayStart); + await service.getTrafficDailyDifferenceByGeneration(mockRequestDto); + + expect(mockTrafficRepository.findTrafficForTimeRange).toHaveBeenNthCalledWith( + 1, + todayStart, + expect.any(Date), + ); + expect(mockTrafficRepository.findTrafficForTimeRange).toHaveBeenNthCalledWith( + 2, + yesterdayStart, + yesterdayEnd, + ); + }); + }); +}); diff --git a/backend/console-server/src/log/traffic/traffic.service.ts b/backend/console-server/src/log/traffic/traffic.service.ts new file mode 100644 index 00000000..04d6c8b8 --- /dev/null +++ b/backend/console-server/src/log/traffic/traffic.service.ts @@ -0,0 +1,131 @@ +import type { GetTrafficTop5Dto } from './dto/get-traffic-top5.dto'; +import { plainToInstance } from 'class-transformer'; +import { GetTrafficRankResponseDto } from './dto/get-traffic-rank-response.dto'; +import type { GetTrafficByGenerationDto } from './dto/get-traffic-by-generation.dto'; +import { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generation-response.dto'; +import type { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; +import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; +import type { GetTrafficByProjectDto } from './dto/get-traffic-by-project.dto'; +import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { + GetTrafficByProjectResponseDto, + TrafficCountByTimeunit, +} from './dto/get-traffic-by-project-response.dto'; +import { Project } from '../../project/entities/project.entity'; +import type { Repository } from 'typeorm'; +import { TrafficRepository } from './traffic.repository'; +import type { GetTrafficTop5ChartDto } from './dto/get-traffic-top5-chart.dto'; +import { + GetTrafficTop5ChartResponseDto, + TrafficTop5Chart, +} from './dto/get-traffic-top5-chart-response.dto'; +import { InjectRepository } from '@nestjs/typeorm'; + +@Injectable() +export class TrafficService { + constructor( + @InjectRepository(Project) + private readonly projectRepository: Repository, + private readonly trafficRepository: TrafficRepository, + ) {} + + async getTrafficTop5(_getTrafficTop5Dto: GetTrafficTop5Dto) { + const result = await this.trafficRepository.findTop5CountByHost(); + + return plainToInstance(GetTrafficRankResponseDto, result); + } + + async getTrafficByGeneration(_getTrafficByGenerationDto: GetTrafficByGenerationDto) { + const result = await this.trafficRepository.findTrafficByGeneration(); + + return plainToInstance(GetTrafficByGenerationResponseDto, result); + } + + private calculateTimeRanges() { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + + const yesterdayStart = new Date(); + yesterdayStart.setDate(yesterdayStart.getDate() - 1); + yesterdayStart.setHours(0, 0, 0, 0); + + const yesterdayEnd = new Date(todayStart); + + return { + today: { start: todayStart, end: new Date() }, + yesterday: { start: yesterdayStart, end: yesterdayEnd }, + }; + } + + private async fetchTrafficData(timeRange: { start: Date; end: Date }) { + const result = await this.trafficRepository.findTrafficForTimeRange( + timeRange.start, + timeRange.end, + ); + return result[0].count; + } + + private formatTrafficDifference(difference: number): string { + return difference > 0 ? `+${difference}` : `${difference}`; + } + + async getTrafficDailyDifferenceByGeneration( + _getTrafficDailyDifferenceDto: GetTrafficDailyDifferenceDto, + ) { + const timeRanges = this.calculateTimeRanges(); + + const [today, yesterday] = await Promise.all([ + this.fetchTrafficData(timeRanges.today), + this.fetchTrafficData(timeRanges.yesterday), + ]); + + const difference = today - yesterday; + const result = { + traffic_daily_difference: this.formatTrafficDifference(difference), + }; + + return plainToInstance(GetTrafficDailyDifferenceResponseDto, result); + } + + async getTrafficByProject(getTrafficByProjectDto: GetTrafficByProjectDto) { + const { projectName, timeUnit } = getTrafficByProjectDto; + + const project = await this.projectRepository.findOne({ + where: { name: projectName }, + select: ['domain'], + }); + if (!project) throw new NotFoundException(`Project with name ${projectName} not found`); + + const results = await this.trafficRepository.findTrafficByProject(project.domain, timeUnit); + + return plainToInstance(GetTrafficByProjectResponseDto, { + projectName, + timeUnit, + trafficData: results.map((result) => plainToInstance(TrafficCountByTimeunit, result)), + }); + } + + async getTrafficTop5Chart(_getTrafficTop5ChartDto: GetTrafficTop5ChartDto) { + const results = await this.trafficRepository.findTrafficTop5Chart(); + + const trafficCharts = await Promise.all( + results.map(async (result) => { + const host = result.host; + const project = await this.projectRepository + .createQueryBuilder('project') + .select('project.name') + .where('project.domain = :domain', { domain: host }) + .getOne(); + + const projectName = project?.name; + + return plainToInstance(TrafficTop5Chart, { + name: projectName, + traffic: result.traffic, + }); + }), + ); + + return plainToInstance(GetTrafficTop5ChartResponseDto, trafficCharts); + } +} From 7c3e8d5fa976f921afa9b98641fa49403889eb56 Mon Sep 17 00:00:00 2001 From: Envyw6567 Date: Sat, 23 Nov 2024 22:56:31 +0900 Subject: [PATCH 04/16] =?UTF-8?q?refactor:=20log=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20=EC=84=9C=EB=B8=8C=EB=8F=84=EB=A9=94=EC=9D=B8(traff?= =?UTF-8?q?ic,=20elapsed-time,=20success-rate)=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/console-server/src/log/log.module.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/console-server/src/log/log.module.ts b/backend/console-server/src/log/log.module.ts index 94ffcd15..ac2194d4 100644 --- a/backend/console-server/src/log/log.module.ts +++ b/backend/console-server/src/log/log.module.ts @@ -5,9 +5,18 @@ import { Project } from '../project/entities/project.entity'; import { ClickhouseModule } from '../clickhouse/clickhouse.module'; import { LogService } from './log.service'; import { LogRepository } from './log.repository'; +import { ElapsedTimeModule } from './elapsed-time/elapsed-time.module'; +import { TrafficModule } from './traffic/traffic.module'; +import { SuccessRateModule } from './success-rate/success-rate.module'; @Module({ - imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], + imports: [ + TypeOrmModule.forFeature([Project]), + ClickhouseModule, + ElapsedTimeModule, + TrafficModule, + SuccessRateModule, + ], providers: [LogService, LogRepository], controllers: [LogController], }) From 2f22e7283eb1d89cc2e0b92d1b07ca82167134dc Mon Sep 17 00:00:00 2001 From: Envyw6567 Date: Sun, 24 Nov 2024 16:03:39 +0900 Subject: [PATCH 05/16] =?UTF-8?q?refactor:=20log=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=9D=98=20DAU=20->=20analytic=20=EC=84=9C=EB=B8=8C?= =?UTF-8?q?=20=EB=8F=84=EB=A9=94=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analytic.controller.spec.ts} | 41 ++++++++-------- .../src/log/analytic/analytic.controller.ts | 25 ++++++++++ .../src/log/analytic/analytic.module.ts | 15 ++++++ .../analytic.repository.spec.ts} | 14 +++--- .../analytic.repository.ts} | 10 ++-- .../analytic.service.spec.ts} | 49 ++++++------------- .../analytic.service.ts} | 25 +++++----- .../dto/get-project-dau-response.dto.ts} | 2 +- .../dto/get-project-dau.dto.ts} | 2 +- .../src/log/analytic/metric/dau.metric.ts | 9 ++++ .../console-server/src/log/log.controller.ts | 25 +--------- backend/console-server/src/log/log.module.ts | 5 +- 12 files changed, 116 insertions(+), 106 deletions(-) rename backend/console-server/src/log/{log.controller.spec.ts => analytic/analytic.controller.spec.ts} (50%) create mode 100644 backend/console-server/src/log/analytic/analytic.controller.ts create mode 100644 backend/console-server/src/log/analytic/analytic.module.ts rename backend/console-server/src/log/{log.repository.spec.ts => analytic/analytic.repository.spec.ts} (91%) rename backend/console-server/src/log/{log.repository.ts => analytic/analytic.repository.ts} (58%) rename backend/console-server/src/log/{log.service.spec.ts => analytic/analytic.service.spec.ts} (69%) rename backend/console-server/src/log/{log.service.ts => analytic/analytic.service.ts} (53%) rename backend/console-server/src/log/{dto/get-dau-by-project-response.dto.ts => analytic/dto/get-project-dau-response.dto.ts} (92%) rename backend/console-server/src/log/{dto/get-dau-by-project.dto.ts => analytic/dto/get-project-dau.dto.ts} (93%) create mode 100644 backend/console-server/src/log/analytic/metric/dau.metric.ts diff --git a/backend/console-server/src/log/log.controller.spec.ts b/backend/console-server/src/log/analytic/analytic.controller.spec.ts similarity index 50% rename from backend/console-server/src/log/log.controller.spec.ts rename to backend/console-server/src/log/analytic/analytic.controller.spec.ts index 8f5e4348..877efa24 100644 --- a/backend/console-server/src/log/log.controller.spec.ts +++ b/backend/console-server/src/log/analytic/analytic.controller.spec.ts @@ -1,29 +1,28 @@ -import { Test } from '@nestjs/testing'; -import type { TestingModule } from '@nestjs/testing'; -import { LogController } from './log.controller'; -import { LogService } from './log.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AnalyticService } from './analytic.service'; +import { AnalyticController } from './analytic.controller'; -describe('LogController 테스트', () => { - let controller: LogController; - let service: LogService; +describe('AnalyticController 테스트', () => { + let controller: AnalyticController; + let service: AnalyticService; const mockLogService = { - getDAUByProject: jest.fn(), + getProjectDAU: jest.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [LogController], + controllers: [AnalyticController], providers: [ { - provide: LogService, + provide: AnalyticService, useValue: mockLogService, }, ], }).compile(); - controller = module.get(LogController); - service = module.get(LogService); + controller = module.get(AnalyticController); + service = module.get(AnalyticService); jest.clearAllMocks(); }); @@ -32,7 +31,7 @@ describe('LogController 테스트', () => { expect(controller).toBeDefined(); }); - describe('getDAUByProject()는', () => { + describe('getProjectDAU()는', () => { const mockRequestDto = { projectName: 'example-project', date: '2024-11-01' }; const mockResponseDto = { @@ -42,26 +41,26 @@ describe('LogController 테스트', () => { }; it('프로젝트명과 날짜가 들어왔을 때 DAU 데이터를 반환해야 한다', async () => { - mockLogService.getDAUByProject.mockResolvedValue(mockResponseDto); + mockLogService.getProjectDAU.mockResolvedValue(mockResponseDto); - const result = await controller.getDAUByProject(mockRequestDto); + const result = await controller.getProjectDAU(mockRequestDto); expect(result).toEqual(mockResponseDto); expect(result).toHaveProperty('projectName', mockRequestDto.projectName); expect(result).toHaveProperty('date', mockRequestDto.date); expect(result).toHaveProperty('dau', 125); - expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getDAUByProject).toHaveBeenCalledTimes(1); + expect(service.getProjectDAU).toHaveBeenCalledWith(mockRequestDto); + expect(service.getProjectDAU).toHaveBeenCalledTimes(1); }); it('서비스 에러 시 예외를 throw 해야 한다', async () => { const error = new Error('Database error'); - mockLogService.getDAUByProject.mockRejectedValue(error); + mockLogService.getProjectDAU.mockRejectedValue(error); - await expect(controller.getDAUByProject(mockRequestDto)).rejects.toThrow(error); + await expect(controller.getProjectDAU(mockRequestDto)).rejects.toThrow(error); - expect(service.getDAUByProject).toHaveBeenCalledWith(mockRequestDto); - expect(service.getDAUByProject).toHaveBeenCalledTimes(1); + expect(service.getProjectDAU).toHaveBeenCalledWith(mockRequestDto); + expect(service.getProjectDAU).toHaveBeenCalledTimes(1); }); }); }); diff --git a/backend/console-server/src/log/analytic/analytic.controller.ts b/backend/console-server/src/log/analytic/analytic.controller.ts new file mode 100644 index 00000000..5f0faa9e --- /dev/null +++ b/backend/console-server/src/log/analytic/analytic.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { GetProjectDauResponseDto } from './dto/get-project-dau-response.dto'; +import { GetProjectDAU } from './dto/get-project-dau.dto'; +import { AnalyticService } from './analytic.service'; + +@Controller('analytic') +export class AnalyticController { + constructor(private readonly analyticService: AnalyticService) {} + + @Get('/dau') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: '프로젝트별 DAU 조회', + description: '프로젝트 이름과 날짜로 해당 프로젝트의 DAU를 반환합니다.', + }) + @ApiResponse({ + status: HttpStatus.OK, + description: '프로젝트의 DAU가 정상적으로 반환됨.', + type: GetProjectDauResponseDto, + }) + async getProjectDAU(@Query() getDAUByProjectDto: GetProjectDAU) { + return await this.analyticService.getProjectDAU(getDAUByProjectDto); + } +} diff --git a/backend/console-server/src/log/analytic/analytic.module.ts b/backend/console-server/src/log/analytic/analytic.module.ts new file mode 100644 index 00000000..3b0ca7d2 --- /dev/null +++ b/backend/console-server/src/log/analytic/analytic.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; +import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import { AnalyticController } from './analytic.controller'; +import { AnalyticService } from './analytic.service'; +import { AnalyticRepository } from './analytic.repository'; + +@Module({ + imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], + providers: [AnalyticService, AnalyticRepository, Clickhouse], + controllers: [AnalyticController], +}) +export class AnalyticModule {} diff --git a/backend/console-server/src/log/log.repository.spec.ts b/backend/console-server/src/log/analytic/analytic.repository.spec.ts similarity index 91% rename from backend/console-server/src/log/log.repository.spec.ts rename to backend/console-server/src/log/analytic/analytic.repository.spec.ts index 2e247585..0a120d70 100644 --- a/backend/console-server/src/log/log.repository.spec.ts +++ b/backend/console-server/src/log/analytic/analytic.repository.spec.ts @@ -1,10 +1,10 @@ -import { Test } from '@nestjs/testing'; -import { LogRepository } from './log.repository'; -import { Clickhouse } from '../clickhouse/clickhouse'; +import { Clickhouse } from '../../clickhouse/clickhouse'; import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { AnalyticRepository } from './analytic.repository'; -describe('LogRepository 테스트', () => { - let repository: LogRepository; +describe('AnalyticRepository 테스트', () => { + let repository: AnalyticRepository; let clickhouse: Clickhouse; const mockClickhouse = { @@ -14,7 +14,7 @@ describe('LogRepository 테스트', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - LogRepository, + AnalyticRepository, { provide: Clickhouse, useValue: mockClickhouse, @@ -22,7 +22,7 @@ describe('LogRepository 테스트', () => { ], }).compile(); - repository = module.get(LogRepository); + repository = module.get(AnalyticRepository); clickhouse = module.get(Clickhouse); }); diff --git a/backend/console-server/src/log/log.repository.ts b/backend/console-server/src/log/analytic/analytic.repository.ts similarity index 58% rename from backend/console-server/src/log/log.repository.ts rename to backend/console-server/src/log/analytic/analytic.repository.ts index fc50d98f..15e16cc6 100644 --- a/backend/console-server/src/log/log.repository.ts +++ b/backend/console-server/src/log/analytic/analytic.repository.ts @@ -1,9 +1,10 @@ -import { Clickhouse } from '../clickhouse/clickhouse'; import { Injectable } from '@nestjs/common'; -import { TimeSeriesQueryBuilder } from '../clickhouse/query-builder/time-series.query-builder'; +import { Clickhouse } from '../../clickhouse/clickhouse'; +import { TimeSeriesQueryBuilder } from '../../clickhouse/query-builder/time-series.query-builder'; +import { DauMetric } from './metric/dau.metric'; @Injectable() -export class LogRepository { +export class AnalyticRepository { constructor(private readonly clickhouse: Clickhouse) {} async findDAUByProject(domain: string, date: string) { @@ -13,7 +14,8 @@ export class LogRepository { .filter({ domain: domain, date: date }) .build(); - const [result] = await this.clickhouse.query<{ dau: number }>(query, params); + const [result] = await this.clickhouse.query(query, params); + return result?.dau ? result.dau : 0; } } diff --git a/backend/console-server/src/log/log.service.spec.ts b/backend/console-server/src/log/analytic/analytic.service.spec.ts similarity index 69% rename from backend/console-server/src/log/log.service.spec.ts rename to backend/console-server/src/log/analytic/analytic.service.spec.ts index 67969cff..d1b2bf9c 100644 --- a/backend/console-server/src/log/log.service.spec.ts +++ b/backend/console-server/src/log/analytic/analytic.service.spec.ts @@ -1,42 +1,24 @@ -import { Test } from '@nestjs/testing'; +import { AnalyticService } from './analytic.service'; +import { AnalyticRepository } from './analytic.repository'; import type { TestingModule } from '@nestjs/testing'; -import { LogService } from './log.service'; -import { LogRepository } from './log.repository'; +import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Project } from '../project/entities/project.entity'; +import { Project } from '../../project/entities/project.entity'; import { NotFoundException } from '@nestjs/common'; -import type { GetTrafficByGenerationResponseDto } from './traffic/dto/get-traffic-by-generation-response.dto'; -import { GetTrafficByGenerationDto } from './traffic/dto/get-traffic-by-generation.dto'; -import { GetProjectSuccessRateResponseDto } from './success-rate/dto/get-project-success-rate-response.dto'; -import type { GetTrafficDailyDifferenceDto } from './traffic/dto/get-traffic-daily-difference.dto'; -import { GetTrafficDailyDifferenceResponseDto } from './traffic/dto/get-traffic-daily-difference-response.dto'; -import { plainToInstance } from 'class-transformer'; -import { GetTrafficTop5Dto } from './traffic/dto/get-traffic-top5.dto'; - -describe('LogService 테스트', () => { - let service: LogService; - let repository: LogRepository; + +describe('AnalyticService 테스트', () => { + let service: AnalyticService; const mockLogRepository = { - findAvgElapsedTime: jest.fn(), - findTop5CountByHost: jest.fn(), - findResponseSuccessRate: jest.fn(), - findResponseSuccessRateByProject: jest.fn(), - findTrafficByGeneration: jest.fn(), - findPathSpeedRankByProject: jest.fn(), - findTrafficByProject: jest.fn(), - findTrafficDailyDifferenceByGeneration: jest.fn(), - findTrafficForTimeRange: jest.fn(), findDAUByProject: jest.fn(), - findSpeedRank: jest.fn(), }; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - LogService, + AnalyticService, { - provide: LogRepository, + provide: AnalyticRepository, useValue: mockLogRepository, }, { @@ -46,8 +28,7 @@ describe('LogService 테스트', () => { ], }).compile(); - service = module.get(LogService); - repository = module.get(LogRepository); + service = module.get(AnalyticService); jest.clearAllMocks(); }); @@ -56,7 +37,7 @@ describe('LogService 테스트', () => { expect(service).toBeDefined(); }); - describe('getDAUByProject()는', () => { + describe('getProjectDAU()는', () => { const mockRequestDto = { projectName: 'example-project', date: '2024-11-01' }; const mockProject = { name: 'example-project', @@ -75,7 +56,7 @@ describe('LogService 테스트', () => { mockLogRepository.findDAUByProject = jest.fn().mockResolvedValue(mockDAUData); - const result = await service.getDAUByProject(mockRequestDto); + const result = await service.getProjectDAU(mockRequestDto); expect(projectRepository.findOne).toHaveBeenCalledWith({ where: { name: mockRequestDto.projectName }, @@ -92,7 +73,7 @@ describe('LogService 테스트', () => { const projectRepository = service['projectRepository']; projectRepository.findOne = jest.fn().mockResolvedValue(null); - await expect(service.getDAUByProject(mockRequestDto)).rejects.toThrow( + await expect(service.getProjectDAU(mockRequestDto)).rejects.toThrow( new NotFoundException(`Project with name ${mockRequestDto.projectName} not found`), ); @@ -109,7 +90,7 @@ describe('LogService 테스트', () => { mockLogRepository.findDAUByProject = jest.fn().mockResolvedValue(0); - const result = await service.getDAUByProject(mockRequestDto); + const result = await service.getProjectDAU(mockRequestDto); expect(projectRepository.findOne).toHaveBeenCalledWith({ where: { name: mockRequestDto.projectName }, @@ -134,7 +115,7 @@ describe('LogService 테스트', () => { .fn() .mockRejectedValue(new Error('Database error')); - await expect(service.getDAUByProject(mockRequestDto)).rejects.toThrow('Database error'); + await expect(service.getProjectDAU(mockRequestDto)).rejects.toThrow('Database error'); expect(projectRepository.findOne).toHaveBeenCalledWith({ where: { name: mockRequestDto.projectName }, diff --git a/backend/console-server/src/log/log.service.ts b/backend/console-server/src/log/analytic/analytic.service.ts similarity index 53% rename from backend/console-server/src/log/log.service.ts rename to backend/console-server/src/log/analytic/analytic.service.ts index 976f7442..31fd4391 100644 --- a/backend/console-server/src/log/log.service.ts +++ b/backend/console-server/src/log/analytic/analytic.service.ts @@ -1,32 +1,33 @@ +import { AnalyticRepository } from './analytic.repository'; import { Injectable, NotFoundException } from '@nestjs/common'; +import { GetProjectDAU } from './dto/get-project-dau.dto'; import { plainToInstance } from 'class-transformer'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Project } from '../project/entities/project.entity'; +import { GetProjectDauResponseDto } from './dto/get-project-dau-response.dto'; import { Repository } from 'typeorm'; -import { LogRepository } from './log.repository'; -import { GetDAUByProjectDto } from './dto/get-dau-by-project.dto'; -import { GetDAUByProjectResponseDto } from './dto/get-dau-by-project-response.dto'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Project } from '../../project/entities/project.entity'; @Injectable() -export class LogService { +export class AnalyticService { constructor( @InjectRepository(Project) private readonly projectRepository: Repository, - private readonly logRepository: LogRepository, + private readonly analyticRepository: AnalyticRepository, ) {} - async getDAUByProject(getDAUByProjectDto: GetDAUByProjectDto) { - const { projectName, date } = getDAUByProjectDto; - + async getProjectDAU(getProjectDAU: GetProjectDAU) { + const { projectName, date } = getProjectDAU; const project = await this.projectRepository.findOne({ where: { name: projectName }, select: ['domain'], }); + if (!project) { throw new NotFoundException(`Project with name ${projectName} not found`); } - const dau = await this.logRepository.findDAUByProject(project.domain, date); - return plainToInstance(GetDAUByProjectResponseDto, { + const dau = await this.analyticRepository.findDAUByProject(project.domain, date); + + return plainToInstance(GetProjectDauResponseDto, { projectName, date, dau, diff --git a/backend/console-server/src/log/dto/get-dau-by-project-response.dto.ts b/backend/console-server/src/log/analytic/dto/get-project-dau-response.dto.ts similarity index 92% rename from backend/console-server/src/log/dto/get-dau-by-project-response.dto.ts rename to backend/console-server/src/log/analytic/dto/get-project-dau-response.dto.ts index 2a3b0883..93eae8e1 100644 --- a/backend/console-server/src/log/dto/get-dau-by-project-response.dto.ts +++ b/backend/console-server/src/log/analytic/dto/get-project-dau-response.dto.ts @@ -2,7 +2,7 @@ import { Exclude, Expose, Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; @Exclude() -export class GetDAUByProjectResponseDto { +export class GetProjectDauResponseDto { @ApiProperty({ example: 'my-project', description: '프로젝트 이름', diff --git a/backend/console-server/src/log/dto/get-dau-by-project.dto.ts b/backend/console-server/src/log/analytic/dto/get-project-dau.dto.ts similarity index 93% rename from backend/console-server/src/log/dto/get-dau-by-project.dto.ts rename to backend/console-server/src/log/analytic/dto/get-project-dau.dto.ts index dc62fc7d..7db63c05 100644 --- a/backend/console-server/src/log/dto/get-dau-by-project.dto.ts +++ b/backend/console-server/src/log/analytic/dto/get-project-dau.dto.ts @@ -1,7 +1,7 @@ import { IsNotEmpty, IsString, IsDateString } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -export class GetDAUByProjectDto { +export class GetProjectDAU { @ApiProperty({ example: 'watchducks', description: '프로젝트 이름', diff --git a/backend/console-server/src/log/analytic/metric/dau.metric.ts b/backend/console-server/src/log/analytic/metric/dau.metric.ts new file mode 100644 index 00000000..4f85f79e --- /dev/null +++ b/backend/console-server/src/log/analytic/metric/dau.metric.ts @@ -0,0 +1,9 @@ +import { Expose, Type } from 'class-transformer'; +import { IsNumber } from 'class-validator'; + +export class DauMetric { + @Expose() + @Type(() => Number) + @IsNumber() + dau: number; +} diff --git a/backend/console-server/src/log/log.controller.ts b/backend/console-server/src/log/log.controller.ts index 798fa0a1..c5dabb7c 100644 --- a/backend/console-server/src/log/log.controller.ts +++ b/backend/console-server/src/log/log.controller.ts @@ -1,25 +1,4 @@ -import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; -import { LogService } from './log.service'; -import { ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { GetDAUByProjectResponseDto } from './dto/get-dau-by-project-response.dto'; -import { GetDAUByProjectDto } from './dto/get-dau-by-project.dto'; +import { Controller } from '@nestjs/common'; @Controller('log') -export class LogController { - constructor(private readonly logService: LogService) {} - - @Get('/dau') - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: '프로젝트별 DAU 조회', - description: '프로젝트 이름과 날짜로 해당 프로젝트의 DAU를 반환합니다.', - }) - @ApiResponse({ - status: HttpStatus.OK, - description: '프로젝트의 DAU가 정상적으로 반환됨.', - type: GetDAUByProjectResponseDto, - }) - async getDAUByProject(@Query() getDAUByProjectDto: GetDAUByProjectDto) { - return await this.logService.getDAUByProject(getDAUByProjectDto); - } -} +export class LogController {} diff --git a/backend/console-server/src/log/log.module.ts b/backend/console-server/src/log/log.module.ts index ac2194d4..90d31169 100644 --- a/backend/console-server/src/log/log.module.ts +++ b/backend/console-server/src/log/log.module.ts @@ -3,11 +3,10 @@ import { LogController } from './log.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Project } from '../project/entities/project.entity'; import { ClickhouseModule } from '../clickhouse/clickhouse.module'; -import { LogService } from './log.service'; -import { LogRepository } from './log.repository'; import { ElapsedTimeModule } from './elapsed-time/elapsed-time.module'; import { TrafficModule } from './traffic/traffic.module'; import { SuccessRateModule } from './success-rate/success-rate.module'; +import { AnalyticModule } from './analytic/analytic.module'; @Module({ imports: [ @@ -16,8 +15,8 @@ import { SuccessRateModule } from './success-rate/success-rate.module'; ElapsedTimeModule, TrafficModule, SuccessRateModule, + AnalyticModule, ], - providers: [LogService, LogRepository], controllers: [LogController], }) export class LogModule {} From a7feeb34cf6ee79242f4bbb7fe6ac967a9334120 Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Sun, 24 Nov 2024 23:36:24 +0900 Subject: [PATCH 06/16] =?UTF-8?q?setting:=20cache-manager=EC=99=80=20redis?= =?UTF-8?q?=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/console-server/package-lock.json | 177 +++++++++++++++++++++++ backend/console-server/package.json | 3 + 2 files changed, 180 insertions(+) diff --git a/backend/console-server/package-lock.json b/backend/console-server/package-lock.json index d3412ed2..45f9f509 100644 --- a/backend/console-server/package-lock.json +++ b/backend/console-server/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@clickhouse/client": "^1.8.0", "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -18,6 +19,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", + "cache-manager": "^5.7.6", + "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "handlebars": "^4.7.8", @@ -1959,6 +1962,18 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/@nestjs/cache-manager": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@nestjs/cache-manager/-/cache-manager-2.3.0.tgz", + "integrity": "sha512-pxeBp9w/s99HaW2+pezM1P3fLiWmUEnTUoUMLa9UYViCtjj0E0A19W/vaT5JFACCzFIeNrwH4/16jkpAhQ25Vw==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^9.0.0 || ^10.0.0", + "@nestjs/core": "^9.0.0 || ^10.0.0", + "cache-manager": "<=5", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/cli": { "version": "10.4.7", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.7.tgz", @@ -2354,6 +2369,71 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@redis/bloom": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz", + "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/client": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.0.tgz", + "integrity": "sha512-aR0uffYI700OEEH4gYnitAnv3vzVGXCFvYfdpu/CJKvk4pHfLPEy/JSZyrpQ+15WhXe1yJRXLtfQ84s4mEXnPg==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2", + "generic-pool": "3.9.0", + "yallist": "4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/@redis/graph": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz", + "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/json": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz", + "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/search": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz", + "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, + "node_modules/@redis/time-series": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz", + "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==", + "license": "MIT", + "peerDependencies": { + "@redis/client": "^1.0.0" + } + }, "node_modules/@scarf/scarf": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", @@ -4033,6 +4113,47 @@ "license": "ISC", "optional": true }, + "node_modules/cache-manager": { + "version": "5.7.6", + "resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-5.7.6.tgz", + "integrity": "sha512-wBxnBHjDxF1RXpHCBD6HGvKER003Ts7IIm0CHpggliHzN1RZditb7rXoduE1rplc2DEFYKxhLKgFuchXMJje9w==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash.clonedeep": "^4.5.0", + "lru-cache": "^10.2.2", + "promise-coalesce": "^1.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager-redis-yet": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/cache-manager-redis-yet/-/cache-manager-redis-yet-5.1.5.tgz", + "integrity": "sha512-NYDxrWBoLXxxVPw4JuBriJW0f45+BVOAsgLiozRo4GoJQyoKPbueQWYStWqmO73/AeHJeWrV7Hzvk6vhCGHlqA==", + "deprecated": "With cache-manager v6 we now are using Keyv", + "license": "MIT", + "dependencies": { + "@redis/bloom": "^1.2.0", + "@redis/client": "^1.6.0", + "@redis/graph": "^1.1.1", + "@redis/json": "^1.0.7", + "@redis/search": "^1.2.0", + "@redis/time-series": "^1.1.0", + "cache-manager": "^5.7.6", + "redis": "^4.7.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/cache-manager/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -4500,6 +4621,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -5736,6 +5866,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -6395,6 +6531,15 @@ "is-property": "^1.0.2" } }, + "node_modules/generic-pool": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz", + "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -8507,6 +8652,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -10658,6 +10809,15 @@ "asap": "~2.0.3" } }, + "node_modules/promise-coalesce": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/promise-coalesce/-/promise-coalesce-1.1.2.tgz", + "integrity": "sha512-zLaJ9b8hnC564fnJH6NFSOGZYYdzrAJn2JUUIwzoQb32fG2QAakpDNM+CZo1km6keXkRXRM+hml1BFAPVnPkxg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16" + } + }, "node_modules/promise-inflight": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", @@ -11044,6 +11204,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.0.tgz", + "integrity": "sha512-zvmkHEAdGMn+hMRXuMBtu4Vo5P6rHQjLoHftu+lBqq8ZTA3RCVC/WzD790bkKKiNFp7d5/9PcSD19fJyyRvOdQ==", + "license": "MIT", + "workspaces": [ + "./packages/*" + ], + "dependencies": { + "@redis/bloom": "1.2.0", + "@redis/client": "1.6.0", + "@redis/graph": "1.1.1", + "@redis/json": "1.0.7", + "@redis/search": "1.2.0", + "@redis/time-series": "1.1.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", diff --git a/backend/console-server/package.json b/backend/console-server/package.json index 5b9979cd..dccdbaf4 100644 --- a/backend/console-server/package.json +++ b/backend/console-server/package.json @@ -23,6 +23,7 @@ "dependencies": { "@clickhouse/client": "^1.8.0", "@nestjs-modules/mailer": "^2.0.2", + "@nestjs/cache-manager": "^2.3.0", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.3.0", "@nestjs/core": "^10.0.0", @@ -30,6 +31,8 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/swagger": "^8.0.5", "@nestjs/typeorm": "^10.0.2", + "cache-manager": "^5.7.6", + "cache-manager-redis-yet": "^5.1.5", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", "handlebars": "^4.7.8", From 08562dce262330000f9864714c5afc4cfc84506e Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Sun, 24 Nov 2024 23:37:11 +0900 Subject: [PATCH 07/16] =?UTF-8?q?setting:=20redis=20config=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../console-server/src/config/redis.config.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 backend/console-server/src/config/redis.config.ts diff --git a/backend/console-server/src/config/redis.config.ts b/backend/console-server/src/config/redis.config.ts new file mode 100644 index 00000000..dd93f8e7 --- /dev/null +++ b/backend/console-server/src/config/redis.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from '@nestjs/config'; +import { redisStore } from 'cache-manager-redis-yet'; + +export default registerAs('redisConfig', async () => { + const store = await redisStore({ + socket: { + host: process.env.REDIS_HOST || 'localhost', + port: Number(process.env.REDIS_PORT) || 6379, + }, + }); + + return { + store: store, + ttl: 3 * 60 * 1000, + }; +}); From 947fecc54f91f699650e64535a15018fa45e9331 Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Sun, 24 Nov 2024 23:38:44 +0900 Subject: [PATCH 08/16] =?UTF-8?q?setting:=20AppModule=EC=97=90=EC=84=9C=20?= =?UTF-8?q?global=EB=A1=9C=20CacheModule=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/console-server/src/app.module.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/console-server/src/app.module.ts b/backend/console-server/src/app.module.ts index 6d2fcc98..b873b7fb 100644 --- a/backend/console-server/src/app.module.ts +++ b/backend/console-server/src/app.module.ts @@ -10,15 +10,21 @@ import mailerConfig from './config/mailer.config'; import { ClickhouseModule } from './clickhouse/clickhouse.module'; import { LogModule } from './log/log.module'; import clickhouseConfig from './config/clickhouse.config'; +import { CacheModule } from '@nestjs/cache-manager'; +import redisConfig from './config/redis.config'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [mailerConfig, clickhouseConfig] }), TypeOrmModule.forRootAsync(typeOrmConfig.asProvider()), - ProjectModule, ClickhouseModule, - LogModule, + CacheModule.registerAsync({ + isGlobal: true, + ...redisConfig.asProvider(), + }), MailModule, + ProjectModule, + LogModule, ], controllers: [AppController], providers: [AppService], From 2cae04d1fe09b5c97a8b412bc2d2bf4f816d0c7a Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Sun, 24 Nov 2024 23:42:28 +0900 Subject: [PATCH 09/16] =?UTF-8?q?setting:=20docker-compose=EC=97=90=20redi?= =?UTF-8?q?s=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/console-server/docker-compose.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/backend/console-server/docker-compose.yml b/backend/console-server/docker-compose.yml index 3cfea514..b35a1389 100644 --- a/backend/console-server/docker-compose.yml +++ b/backend/console-server/docker-compose.yml @@ -1,5 +1,15 @@ version: '3.8' services: + + redis: + image: redis:latest + container_name: app-redis + restart: always + ports: + - "6379:6379" + networks: + - app-network + nginx: image: nginx:latest ports: From d35923e1785163880f553820068b5dd3e60fe32c Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Mon, 25 Nov 2024 00:02:34 +0900 Subject: [PATCH 10/16] =?UTF-8?q?style:=20analytics=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20=EB=B3=80=EA=B2=BD=20-=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=84=EB=B6=80=20pass=20=ED=99=95=EC=9D=B8=20Te?= =?UTF-8?q?st=20Suites:=2017=20passed,=2017=20total=20Tests:=20=20=20=20?= =?UTF-8?q?=20=20=20110=20passed,=20110=20total=20Snapshots:=20=20=200=20t?= =?UTF-8?q?otal=20Time:=20=20=20=20=20=20=20=204.954=20s,=20estimated=205?= =?UTF-8?q?=20s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analytics.controller.spec.ts} | 21 ++++++++++--------- .../analytics.controller.ts} | 8 +++---- .../analytics.module.ts} | 12 +++++------ .../analytics.repository.spec.ts} | 10 ++++----- .../analytics.repository.ts} | 2 +- .../analytics.service.spec.ts} | 14 ++++++------- .../analytics.service.ts} | 6 +++--- .../dto/get-project-dau-response.dto.ts | 0 .../dto/get-project-dau.dto.ts | 0 .../metric/dau.metric.ts | 0 backend/console-server/src/log/log.module.ts | 4 ++-- 11 files changed, 39 insertions(+), 38 deletions(-) rename backend/console-server/src/log/{analytic/analytic.controller.spec.ts => analytics/analytics.controller.spec.ts} (76%) rename backend/console-server/src/log/{analytic/analytic.controller.ts => analytics/analytics.controller.ts} (81%) rename backend/console-server/src/log/{analytic/analytic.module.ts => analytics/analytics.module.ts} (53%) rename backend/console-server/src/log/{analytic/analytic.repository.spec.ts => analytics/analytics.repository.spec.ts} (92%) rename backend/console-server/src/log/{analytic/analytic.repository.ts => analytics/analytics.repository.ts} (95%) rename backend/console-server/src/log/{analytic/analytic.service.spec.ts => analytics/analytics.service.spec.ts} (93%) rename backend/console-server/src/log/{analytic/analytic.service.ts => analytics/analytics.service.ts} (87%) rename backend/console-server/src/log/{analytic => analytics}/dto/get-project-dau-response.dto.ts (100%) rename backend/console-server/src/log/{analytic => analytics}/dto/get-project-dau.dto.ts (100%) rename backend/console-server/src/log/{analytic => analytics}/metric/dau.metric.ts (100%) diff --git a/backend/console-server/src/log/analytic/analytic.controller.spec.ts b/backend/console-server/src/log/analytics/analytics.controller.spec.ts similarity index 76% rename from backend/console-server/src/log/analytic/analytic.controller.spec.ts rename to backend/console-server/src/log/analytics/analytics.controller.spec.ts index 877efa24..577ab274 100644 --- a/backend/console-server/src/log/analytic/analytic.controller.spec.ts +++ b/backend/console-server/src/log/analytics/analytics.controller.spec.ts @@ -1,10 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AnalyticService } from './analytic.service'; -import { AnalyticController } from './analytic.controller'; +import type { TestingModule } from '@nestjs/testing'; +import { Test } from '@nestjs/testing'; +import { AnalyticsService } from './analytics.service'; +import { AnalyticsController } from './analytics.controller'; -describe('AnalyticController 테스트', () => { - let controller: AnalyticController; - let service: AnalyticService; +describe('AnalyticsController 테스트', () => { + let controller: AnalyticsController; + let service: AnalyticsService; const mockLogService = { getProjectDAU: jest.fn(), @@ -12,17 +13,17 @@ describe('AnalyticController 테스트', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [AnalyticController], + controllers: [AnalyticsController], providers: [ { - provide: AnalyticService, + provide: AnalyticsService, useValue: mockLogService, }, ], }).compile(); - controller = module.get(AnalyticController); - service = module.get(AnalyticService); + controller = module.get(AnalyticsController); + service = module.get(AnalyticsService); jest.clearAllMocks(); }); diff --git a/backend/console-server/src/log/analytic/analytic.controller.ts b/backend/console-server/src/log/analytics/analytics.controller.ts similarity index 81% rename from backend/console-server/src/log/analytic/analytic.controller.ts rename to backend/console-server/src/log/analytics/analytics.controller.ts index 5f0faa9e..b2087cf7 100644 --- a/backend/console-server/src/log/analytic/analytic.controller.ts +++ b/backend/console-server/src/log/analytics/analytics.controller.ts @@ -2,11 +2,11 @@ import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { GetProjectDauResponseDto } from './dto/get-project-dau-response.dto'; import { GetProjectDAU } from './dto/get-project-dau.dto'; -import { AnalyticService } from './analytic.service'; +import { AnalyticsService } from './analytics.service'; -@Controller('analytic') -export class AnalyticController { - constructor(private readonly analyticService: AnalyticService) {} +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analyticService: AnalyticsService) {} @Get('/dau') @HttpCode(HttpStatus.OK) diff --git a/backend/console-server/src/log/analytic/analytic.module.ts b/backend/console-server/src/log/analytics/analytics.module.ts similarity index 53% rename from backend/console-server/src/log/analytic/analytic.module.ts rename to backend/console-server/src/log/analytics/analytics.module.ts index 3b0ca7d2..d4f55c9e 100644 --- a/backend/console-server/src/log/analytic/analytic.module.ts +++ b/backend/console-server/src/log/analytics/analytics.module.ts @@ -3,13 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Project } from '../../project/entities/project.entity'; import { ClickhouseModule } from '../../clickhouse/clickhouse.module'; import { Clickhouse } from '../../clickhouse/clickhouse'; -import { AnalyticController } from './analytic.controller'; -import { AnalyticService } from './analytic.service'; -import { AnalyticRepository } from './analytic.repository'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; +import { AnalyticsRepository } from './analytics.repository'; @Module({ imports: [TypeOrmModule.forFeature([Project]), ClickhouseModule], - providers: [AnalyticService, AnalyticRepository, Clickhouse], - controllers: [AnalyticController], + providers: [AnalyticsService, AnalyticsRepository, Clickhouse], + controllers: [AnalyticsController], }) -export class AnalyticModule {} +export class AnalyticsModule {} diff --git a/backend/console-server/src/log/analytic/analytic.repository.spec.ts b/backend/console-server/src/log/analytics/analytics.repository.spec.ts similarity index 92% rename from backend/console-server/src/log/analytic/analytic.repository.spec.ts rename to backend/console-server/src/log/analytics/analytics.repository.spec.ts index 0a120d70..987683e7 100644 --- a/backend/console-server/src/log/analytic/analytic.repository.spec.ts +++ b/backend/console-server/src/log/analytics/analytics.repository.spec.ts @@ -1,10 +1,10 @@ import { Clickhouse } from '../../clickhouse/clickhouse'; import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; -import { AnalyticRepository } from './analytic.repository'; +import { AnalyticsRepository } from './analytics.repository'; -describe('AnalyticRepository 테스트', () => { - let repository: AnalyticRepository; +describe('AnalyticsRepository 테스트', () => { + let repository: AnalyticsRepository; let clickhouse: Clickhouse; const mockClickhouse = { @@ -14,7 +14,7 @@ describe('AnalyticRepository 테스트', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - AnalyticRepository, + AnalyticsRepository, { provide: Clickhouse, useValue: mockClickhouse, @@ -22,7 +22,7 @@ describe('AnalyticRepository 테스트', () => { ], }).compile(); - repository = module.get(AnalyticRepository); + repository = module.get(AnalyticsRepository); clickhouse = module.get(Clickhouse); }); diff --git a/backend/console-server/src/log/analytic/analytic.repository.ts b/backend/console-server/src/log/analytics/analytics.repository.ts similarity index 95% rename from backend/console-server/src/log/analytic/analytic.repository.ts rename to backend/console-server/src/log/analytics/analytics.repository.ts index 15e16cc6..39f6acf4 100644 --- a/backend/console-server/src/log/analytic/analytic.repository.ts +++ b/backend/console-server/src/log/analytics/analytics.repository.ts @@ -4,7 +4,7 @@ import { TimeSeriesQueryBuilder } from '../../clickhouse/query-builder/time-seri import { DauMetric } from './metric/dau.metric'; @Injectable() -export class AnalyticRepository { +export class AnalyticsRepository { constructor(private readonly clickhouse: Clickhouse) {} async findDAUByProject(domain: string, date: string) { diff --git a/backend/console-server/src/log/analytic/analytic.service.spec.ts b/backend/console-server/src/log/analytics/analytics.service.spec.ts similarity index 93% rename from backend/console-server/src/log/analytic/analytic.service.spec.ts rename to backend/console-server/src/log/analytics/analytics.service.spec.ts index d1b2bf9c..72f796a4 100644 --- a/backend/console-server/src/log/analytic/analytic.service.spec.ts +++ b/backend/console-server/src/log/analytics/analytics.service.spec.ts @@ -1,13 +1,13 @@ -import { AnalyticService } from './analytic.service'; -import { AnalyticRepository } from './analytic.repository'; +import { AnalyticsService } from './analytics.service'; +import { AnalyticsRepository } from './analytics.repository'; import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Project } from '../../project/entities/project.entity'; import { NotFoundException } from '@nestjs/common'; -describe('AnalyticService 테스트', () => { - let service: AnalyticService; +describe('AnalyticsService 테스트', () => { + let service: AnalyticsService; const mockLogRepository = { findDAUByProject: jest.fn(), @@ -16,9 +16,9 @@ describe('AnalyticService 테스트', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - AnalyticService, + AnalyticsService, { - provide: AnalyticRepository, + provide: AnalyticsRepository, useValue: mockLogRepository, }, { @@ -28,7 +28,7 @@ describe('AnalyticService 테스트', () => { ], }).compile(); - service = module.get(AnalyticService); + service = module.get(AnalyticsService); jest.clearAllMocks(); }); diff --git a/backend/console-server/src/log/analytic/analytic.service.ts b/backend/console-server/src/log/analytics/analytics.service.ts similarity index 87% rename from backend/console-server/src/log/analytic/analytic.service.ts rename to backend/console-server/src/log/analytics/analytics.service.ts index 31fd4391..fc98f175 100644 --- a/backend/console-server/src/log/analytic/analytic.service.ts +++ b/backend/console-server/src/log/analytics/analytics.service.ts @@ -1,4 +1,4 @@ -import { AnalyticRepository } from './analytic.repository'; +import { AnalyticsRepository } from './analytics.repository'; import { Injectable, NotFoundException } from '@nestjs/common'; import { GetProjectDAU } from './dto/get-project-dau.dto'; import { plainToInstance } from 'class-transformer'; @@ -8,11 +8,11 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Project } from '../../project/entities/project.entity'; @Injectable() -export class AnalyticService { +export class AnalyticsService { constructor( @InjectRepository(Project) private readonly projectRepository: Repository, - private readonly analyticRepository: AnalyticRepository, + private readonly analyticRepository: AnalyticsRepository, ) {} async getProjectDAU(getProjectDAU: GetProjectDAU) { diff --git a/backend/console-server/src/log/analytic/dto/get-project-dau-response.dto.ts b/backend/console-server/src/log/analytics/dto/get-project-dau-response.dto.ts similarity index 100% rename from backend/console-server/src/log/analytic/dto/get-project-dau-response.dto.ts rename to backend/console-server/src/log/analytics/dto/get-project-dau-response.dto.ts diff --git a/backend/console-server/src/log/analytic/dto/get-project-dau.dto.ts b/backend/console-server/src/log/analytics/dto/get-project-dau.dto.ts similarity index 100% rename from backend/console-server/src/log/analytic/dto/get-project-dau.dto.ts rename to backend/console-server/src/log/analytics/dto/get-project-dau.dto.ts diff --git a/backend/console-server/src/log/analytic/metric/dau.metric.ts b/backend/console-server/src/log/analytics/metric/dau.metric.ts similarity index 100% rename from backend/console-server/src/log/analytic/metric/dau.metric.ts rename to backend/console-server/src/log/analytics/metric/dau.metric.ts diff --git a/backend/console-server/src/log/log.module.ts b/backend/console-server/src/log/log.module.ts index 90d31169..1a211716 100644 --- a/backend/console-server/src/log/log.module.ts +++ b/backend/console-server/src/log/log.module.ts @@ -6,7 +6,7 @@ import { ClickhouseModule } from '../clickhouse/clickhouse.module'; import { ElapsedTimeModule } from './elapsed-time/elapsed-time.module'; import { TrafficModule } from './traffic/traffic.module'; import { SuccessRateModule } from './success-rate/success-rate.module'; -import { AnalyticModule } from './analytic/analytic.module'; +import { AnalyticsModule } from './analytics/analytics.module'; @Module({ imports: [ @@ -15,7 +15,7 @@ import { AnalyticModule } from './analytic/analytic.module'; ElapsedTimeModule, TrafficModule, SuccessRateModule, - AnalyticModule, + AnalyticsModule, ], controllers: [LogController], }) From b3a4b2fb38643ad3c9b6e64e66436f137f2a7011 Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Mon, 25 Nov 2024 00:28:43 +0900 Subject: [PATCH 11/16] =?UTF-8?q?chore:=20success-rate=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20prefix=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/log/success-rate/success-rate.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/console-server/src/log/success-rate/success-rate.controller.ts b/backend/console-server/src/log/success-rate/success-rate.controller.ts index b00e5f35..6c324975 100644 --- a/backend/console-server/src/log/success-rate/success-rate.controller.ts +++ b/backend/console-server/src/log/success-rate/success-rate.controller.ts @@ -10,7 +10,7 @@ import { GetProjectSuccessRateDto } from './dto/get-project-success-rate.dto'; export class SuccessRateController { constructor(private readonly successRateService: SuccessRateService) {} - @Get('/success-rate') + @Get('') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '기수 내 응답 성공률', @@ -25,7 +25,7 @@ export class SuccessRateController { return await this.successRateService.getSuccessRate(getSuccessRateDto); } - @Get('/success-rate/project') + @Get('/project') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '프로젝트 별 응답 성공률', From 18f8598e4208e9491e9abc50dce0126a4c09f187 Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Mon, 25 Nov 2024 00:29:44 +0900 Subject: [PATCH 12/16] =?UTF-8?q?feat:=20elapsed-time,=20success-rate=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=EB=8B=A8=EC=97=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=20=EC=BA=90=EC=8B=9C=20Interceptor=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9?= =?UTF-8?q?=EC=9E=85=EB=8B=88=EB=8B=A4.=20=EC=BA=90=EC=8B=9C=EC=A0=95?= =?UTF-8?q?=EC=B1=85=EC=9D=80=20=ED=95=A8=EA=BB=98=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=98=EB=85=BC=ED=95=B4=EB=B3=B4=EC=95=84=EC=9A=94.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/log/elapsed-time/elapsed-time.controller.ts | 4 +++- .../src/log/success-rate/success-rate.controller.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts index 5a2a330f..f3508516 100644 --- a/backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Query, UseInterceptors } from '@nestjs/common'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { GetAvgElapsedTimeResponseDto } from './dto/get-avg-elapsed-time-response.dto'; import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; @@ -7,8 +7,10 @@ import { GetTop5ElapsedTime } from './dto/get-top5-elapsed.time'; import { GetTop5ElapsedTimeDto } from './dto/get-top5-elapsed-time.dto'; import { GetPathElapsedTimeResponseDto } from './dto/get-path-elapsed-time-response.dto'; import { GetPathElapsedTimeRank } from './dto/get-path-elapsed-time.rank'; +import { CacheInterceptor } from '@nestjs/cache-manager'; @Controller('elapsed-time') +@UseInterceptors(CacheInterceptor) export class ElapsedTimeController { constructor(private readonly elapsedTimeService: ElapsedTimeService) {} diff --git a/backend/console-server/src/log/success-rate/success-rate.controller.ts b/backend/console-server/src/log/success-rate/success-rate.controller.ts index 6c324975..589cf5e0 100644 --- a/backend/console-server/src/log/success-rate/success-rate.controller.ts +++ b/backend/console-server/src/log/success-rate/success-rate.controller.ts @@ -1,12 +1,14 @@ -import { Controller, Get, HttpCode, HttpStatus, Query } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, Query, UseInterceptors } from '@nestjs/common'; import { SuccessRateService } from './success-rate.service'; import { ApiOperation, ApiResponse } from '@nestjs/swagger'; import { GetSuccessRateResponseDto } from './dto/get-success-rate-response.dto'; import { GetSuccessRateDto } from './dto/get-success-rate.dto'; import { GetProjectSuccessRateResponseDto } from './dto/get-project-success-rate-response.dto'; import { GetProjectSuccessRateDto } from './dto/get-project-success-rate.dto'; +import { CacheInterceptor } from '@nestjs/cache-manager'; @Controller('success-rate') +@UseInterceptors(CacheInterceptor) export class SuccessRateController { constructor(private readonly successRateService: SuccessRateService) {} From 93ae2f435f8744655cac9ffafef2284e16045d38 Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Mon, 25 Nov 2024 00:58:28 +0900 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20CacheModule=EC=97=90=20RedisClient?= =?UTF-8?q?Options=20=ED=83=80=EC=9E=85=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EB=84=98=EA=B8=B0=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/console-server/package-lock.json | 1 + backend/console-server/package.json | 1 + backend/console-server/src/app.module.ts | 3 ++- backend/console-server/src/log/traffic/traffic.service.ts | 2 +- 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/console-server/package-lock.json b/backend/console-server/package-lock.json index 45f9f509..9df5528c 100644 --- a/backend/console-server/package-lock.json +++ b/backend/console-server/package-lock.json @@ -26,6 +26,7 @@ "handlebars": "^4.7.8", "mysql2": "^3.11.4", "nodemailer": "^6.9.16", + "redis": "^4.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", diff --git a/backend/console-server/package.json b/backend/console-server/package.json index dccdbaf4..a0310d61 100644 --- a/backend/console-server/package.json +++ b/backend/console-server/package.json @@ -38,6 +38,7 @@ "handlebars": "^4.7.8", "mysql2": "^3.11.4", "nodemailer": "^6.9.16", + "redis": "^4.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", diff --git a/backend/console-server/src/app.module.ts b/backend/console-server/src/app.module.ts index b873b7fb..5d108e89 100644 --- a/backend/console-server/src/app.module.ts +++ b/backend/console-server/src/app.module.ts @@ -12,13 +12,14 @@ import { LogModule } from './log/log.module'; import clickhouseConfig from './config/clickhouse.config'; import { CacheModule } from '@nestjs/cache-manager'; import redisConfig from './config/redis.config'; +import { RedisClientOptions } from 'redis'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [mailerConfig, clickhouseConfig] }), TypeOrmModule.forRootAsync(typeOrmConfig.asProvider()), ClickhouseModule, - CacheModule.registerAsync({ + CacheModule.registerAsync({ isGlobal: true, ...redisConfig.asProvider(), }), diff --git a/backend/console-server/src/log/traffic/traffic.service.ts b/backend/console-server/src/log/traffic/traffic.service.ts index 04d6c8b8..011cc5b8 100644 --- a/backend/console-server/src/log/traffic/traffic.service.ts +++ b/backend/console-server/src/log/traffic/traffic.service.ts @@ -6,7 +6,7 @@ import { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generati import type { GetTrafficDailyDifferenceDto } from './dto/get-traffic-daily-difference.dto'; import { GetTrafficDailyDifferenceResponseDto } from './dto/get-traffic-daily-difference-response.dto'; import type { GetTrafficByProjectDto } from './dto/get-traffic-by-project.dto'; -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { GetTrafficByProjectResponseDto, TrafficCountByTimeunit, From 289e5fa819e4f88a5261cd20371c0d8de90f3b3a Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Mon, 25 Nov 2024 01:07:46 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20config=EC=97=90=20ttl=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/console-server/package-lock.json | 1 - backend/console-server/package.json | 1 - backend/console-server/src/app.module.ts | 3 +-- backend/console-server/src/config/redis.config.ts | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/backend/console-server/package-lock.json b/backend/console-server/package-lock.json index 9df5528c..45f9f509 100644 --- a/backend/console-server/package-lock.json +++ b/backend/console-server/package-lock.json @@ -26,7 +26,6 @@ "handlebars": "^4.7.8", "mysql2": "^3.11.4", "nodemailer": "^6.9.16", - "redis": "^4.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", diff --git a/backend/console-server/package.json b/backend/console-server/package.json index a0310d61..dccdbaf4 100644 --- a/backend/console-server/package.json +++ b/backend/console-server/package.json @@ -38,7 +38,6 @@ "handlebars": "^4.7.8", "mysql2": "^3.11.4", "nodemailer": "^6.9.16", - "redis": "^4.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", "sqlite3": "^5.1.7", diff --git a/backend/console-server/src/app.module.ts b/backend/console-server/src/app.module.ts index 5d108e89..b873b7fb 100644 --- a/backend/console-server/src/app.module.ts +++ b/backend/console-server/src/app.module.ts @@ -12,14 +12,13 @@ import { LogModule } from './log/log.module'; import clickhouseConfig from './config/clickhouse.config'; import { CacheModule } from '@nestjs/cache-manager'; import redisConfig from './config/redis.config'; -import { RedisClientOptions } from 'redis'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, load: [mailerConfig, clickhouseConfig] }), TypeOrmModule.forRootAsync(typeOrmConfig.asProvider()), ClickhouseModule, - CacheModule.registerAsync({ + CacheModule.registerAsync({ isGlobal: true, ...redisConfig.asProvider(), }), diff --git a/backend/console-server/src/config/redis.config.ts b/backend/console-server/src/config/redis.config.ts index dd93f8e7..c0e4a60e 100644 --- a/backend/console-server/src/config/redis.config.ts +++ b/backend/console-server/src/config/redis.config.ts @@ -7,10 +7,10 @@ export default registerAs('redisConfig', async () => { host: process.env.REDIS_HOST || 'localhost', port: Number(process.env.REDIS_PORT) || 6379, }, + ttl: 60 * 1000, }); return { store: store, - ttl: 3 * 60 * 1000, }; }); From 50e12f0b673fb4aedfeeb51c5bc53fb41d3c820b Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Mon, 25 Nov 2024 01:16:58 +0900 Subject: [PATCH 15/16] =?UTF-8?q?chore:=20linter=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/console-server/src/app.service.ts | 6 +++--- .../src/log/elapsed-time/elapsed-time.service.ts | 2 +- .../dto/get-traffic-daily-difference-response.dto.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/console-server/src/app.service.ts b/backend/console-server/src/app.service.ts index 927d7cca..61b7a5b6 100644 --- a/backend/console-server/src/app.service.ts +++ b/backend/console-server/src/app.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { - getHello(): string { - return 'Hello World!'; - } + getHello(): string { + return 'Hello World!'; + } } diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.service.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.service.ts index a4bbd34f..055a51b6 100644 --- a/backend/console-server/src/log/elapsed-time/elapsed-time.service.ts +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.service.ts @@ -6,7 +6,7 @@ import type { GetTop5ElapsedTimeDto } from './dto/get-top5-elapsed-time.dto'; import { GetTop5ElapsedTime, ProjectElapsedTime } from './dto/get-top5-elapsed.time'; import type { Repository } from 'typeorm'; import type { GetPathElapsedTimeRank } from './dto/get-path-elapsed-time.rank'; -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { GetPathElapsedTimeResponseDto, PathResponseTime, diff --git a/backend/console-server/src/log/traffic/dto/get-traffic-daily-difference-response.dto.ts b/backend/console-server/src/log/traffic/dto/get-traffic-daily-difference-response.dto.ts index 6972cf7b..02bb1293 100644 --- a/backend/console-server/src/log/traffic/dto/get-traffic-daily-difference-response.dto.ts +++ b/backend/console-server/src/log/traffic/dto/get-traffic-daily-difference-response.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; +import { IsNotEmpty, IsString } from 'class-validator'; export class GetTrafficDailyDifferenceResponseDto { @ApiProperty({ From d85444a365688a93f649fdecec0a6655215a9b7a Mon Sep 17 00:00:00 2001 From: sjy2335 Date: Mon, 25 Nov 2024 01:59:39 +0900 Subject: [PATCH 16/16] =?UTF-8?q?fix:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=97=90=20mockCacheManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/log/analytics/analytics.controller.spec.ts | 11 +++++++++++ .../log/elapsed-time/elapsed-time.controller.spec.ts | 11 +++++++++++ .../log/success-rate/success-rate.controller.spec.ts | 11 +++++++++++ 3 files changed, 33 insertions(+) diff --git a/backend/console-server/src/log/analytics/analytics.controller.spec.ts b/backend/console-server/src/log/analytics/analytics.controller.spec.ts index 577ab274..8aa59df3 100644 --- a/backend/console-server/src/log/analytics/analytics.controller.spec.ts +++ b/backend/console-server/src/log/analytics/analytics.controller.spec.ts @@ -2,6 +2,7 @@ import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; import { AnalyticsService } from './analytics.service'; import { AnalyticsController } from './analytics.controller'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; describe('AnalyticsController 테스트', () => { let controller: AnalyticsController; @@ -11,6 +12,12 @@ describe('AnalyticsController 테스트', () => { getProjectDAU: jest.fn(), }; + const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [AnalyticsController], @@ -19,6 +26,10 @@ describe('AnalyticsController 테스트', () => { provide: AnalyticsService, useValue: mockLogService, }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, ], }).compile(); diff --git a/backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts index 9731e8df..47be0238 100644 --- a/backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts +++ b/backend/console-server/src/log/elapsed-time/elapsed-time.controller.spec.ts @@ -5,6 +5,7 @@ import { Test } from '@nestjs/testing'; import { HttpStatus } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { GetAvgElapsedTimeDto } from './dto/get-avg-elapsed-time.dto'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; describe('ElapsedTimeController 테스트', () => { let controller: ElapsedTimeController; @@ -16,6 +17,12 @@ describe('ElapsedTimeController 테스트', () => { getTop5ElapsedTime: jest.fn(), }; + const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [ElapsedTimeController], @@ -24,6 +31,10 @@ describe('ElapsedTimeController 테스트', () => { provide: ElapsedTimeService, useValue: mockElapsedTimeService, }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, ], }).compile(); diff --git a/backend/console-server/src/log/success-rate/success-rate.controller.spec.ts b/backend/console-server/src/log/success-rate/success-rate.controller.spec.ts index f2abe0e4..8490d3db 100644 --- a/backend/console-server/src/log/success-rate/success-rate.controller.spec.ts +++ b/backend/console-server/src/log/success-rate/success-rate.controller.spec.ts @@ -2,6 +2,7 @@ import { SuccessRateController } from './success-rate.controller'; import { SuccessRateService } from './success-rate.service'; import type { TestingModule } from '@nestjs/testing'; import { Test } from '@nestjs/testing'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; describe('SuccessRateController 테스트', () => { let controller: SuccessRateController; @@ -12,6 +13,12 @@ describe('SuccessRateController 테스트', () => { getProjectSuccessRate: jest.fn(), }; + const mockCacheManager = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [SuccessRateController], @@ -20,6 +27,10 @@ describe('SuccessRateController 테스트', () => { provide: SuccessRateService, useValue: mockSuccessRateService, }, + { + provide: CACHE_MANAGER, + useValue: mockCacheManager, + }, ], }).compile();