Skip to content

Commit

Permalink
Merge pull request #136 from boostcampwm-2024/be-feat#135
Browse files Browse the repository at this point in the history
[BE] TOP5 응답 속도 API
  • Loading branch information
EnvyW6567 authored Nov 21, 2024
2 parents 0be45d6 + 41114e3 commit a2f28eb
Show file tree
Hide file tree
Showing 9 changed files with 199 additions and 180 deletions.
28 changes: 28 additions & 0 deletions backend/console-server/src/log/dto/get-speed-rank-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Exclude, Expose, Type } from 'class-transformer';
import { ApiProperty } from '@nestjs/swagger';

export class ProjectSpeedData {
@ApiProperty({
example: 'watchducks',
description: '해당 프로젝트명',
})
@Expose()
projectName: string;

@ApiProperty({
example: 123.45,
description: '평균 응답 소요 시간 (ms).',
})
@Expose()
@Type(() => Number)
avgResponseTime: number;
}

export class GetSpeedRankResponseDto {
@ApiProperty({
type: [ProjectSpeedData],
description: '프로젝트별 응답 속도 배열',
})
@Type(() => ProjectSpeedData)
projectSpeedRank: ProjectSpeedData[];
}
14 changes: 14 additions & 0 deletions backend/console-server/src/log/dto/get-speed-rank.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber } from 'class-validator';

export class GetSpeedRankDto {
@IsNumber()
@Type(() => Number)
@ApiProperty({
description: '기수',
example: 5,
required: true,
})
generation: number;
}
57 changes: 53 additions & 4 deletions backend/console-server/src/log/log.contorller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ describe('LogController 테스트', () => {

const mockLogService = {
httpLog: jest.fn(),
elapsedTime: jest.fn(),
getAvgElapsedTime: jest.fn(),
trafficRank: jest.fn(),
getResponseSuccessRate: jest.fn(),
getResponseSuccessRateByProject: jest.fn(),
Expand All @@ -27,6 +27,7 @@ describe('LogController 테스트', () => {
getTrafficByProject: jest.fn(),
getTrafficDailyDifferenceByGeneration: jest.fn(),
getDAUByProject: jest.fn(),
getSpeedRank: jest.fn(),
};

beforeEach(async () => {
Expand Down Expand Up @@ -57,9 +58,10 @@ describe('LogController 테스트', () => {
};

it('평균 응답 시간을 ProjectResponseDto 형식으로 반환해야 한다', async () => {
mockLogService.elapsedTime.mockResolvedValue(mockResult);
mockLogService.getAvgElapsedTime.mockResolvedValue(mockResult);

const result = await controller.elapsedTime(

const result = await controller.getElapsedTime(
plainToInstance(GetAvgElapsedTimeDto, { generation: 1 }),
);

Expand All @@ -85,7 +87,7 @@ describe('LogController 테스트', () => {
it('TOP 5 트래픽 순위를 ProjectResponseDto 형식으로 반환해야 한다', async () => {
mockLogService.trafficRank.mockResolvedValue(mockResult);

const result = (await controller.trafficRank(
const result = (await controller.getTrafficRank(
plainToInstance(GetTrafficRankDto, { generation: 1 }),
)) as unknown as TrafficRankResponseType;

Expand Down Expand Up @@ -343,4 +345,51 @@ describe('LogController 테스트', () => {
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);
});
});
});
21 changes: 19 additions & 2 deletions backend/console-server/src/log/log.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { GetTrafficByGenerationResponseDto } from './dto/get-traffic-by-generati
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';
Expand All @@ -37,7 +39,7 @@ export class LogController {
description: '평균 응답시간이 성공적으로 반환됨.',
type: GetAvgElapsedTimeResponseDto,
})
async elapsedTime(@Query() getAvgElapsedTimeDto: GetAvgElapsedTimeDto) {
async getElapsedTime(@Query() getAvgElapsedTimeDto: GetAvgElapsedTimeDto) {
return await this.logService.getAvgElapsedTime(getAvgElapsedTimeDto);
}

Expand All @@ -52,10 +54,25 @@ export class LogController {
description: '트래픽 랭킹 TOP 5가 정상적으로 반환됨.',
type: GetTrafficRankResponseDto,
})
async trafficRank(@Query() getTrafficRankDto: GetTrafficRankDto) {
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({
Expand Down
74 changes: 11 additions & 63 deletions backend/console-server/src/log/log.repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe('LogRepository 테스트', () => {

describe('findTrafficByGeneration()는 ', () => {
it('전체 트래픽 수를 반환해야 한다.', async () => {
const mockResult = [{ count: 5000 }];
const mockResult = { count: 5000 };
mockClickhouse.query.mockResolvedValue(mockResult);

const result = await repository.findTrafficByGeneration();
Expand All @@ -142,58 +142,6 @@ describe('LogRepository 테스트', () => {
});
});

describe('getPathSpeedRankByProject()는 ', () => {
const domain = 'example.com';
const mockFastestPaths = [
{ 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 },
];
const mockSlowestPaths = [
{ 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('도메인을 기준으로 Top 3 fastest 경로와 slowest 경로를 반환해야 한다.', async () => {
mockClickhouse.query
.mockResolvedValueOnce(mockFastestPaths)
.mockResolvedValueOnce(mockSlowestPaths);

const result = await repository.getFastestPathsByDomain(domain);

expect(result).toEqual({
fastestPaths: mockFastestPaths,
slowestPaths: mockSlowestPaths,
});

expect(clickhouse.query).toHaveBeenCalledTimes(2);
expect(clickhouse.query).toHaveBeenNthCalledWith(
1,
expect.stringMatching(
/SELECT\s+avg\(elapsed_time\) as avg_elapsed_time,\s+path\s+FROM http_log\s+WHERE host = \{host:String}\s+GROUP BY path\s+ORDER BY avg_elapsed_time\s+LIMIT 3/,
),
expect.objectContaining({ host: domain }),
);
expect(clickhouse.query).toHaveBeenNthCalledWith(
2,
expect.stringMatching(
/SELECT\s+avg\(elapsed_time\) as avg_elapsed_time,\s+path\s+FROM http_log\s+WHERE host = \{host:String}\s+GROUP BY path\s+ORDER BY avg_elapsed_time DESC\s+LIMIT 3/,
),
expect.objectContaining({ host: domain }),
);
});

it('클릭하우스 에러 발생 시 예외를 throw 해야 한다.', async () => {
const error = new Error('Clickhouse query failed');
mockClickhouse.query.mockRejectedValue(error);

await expect(repository.getFastestPathsByDomain(domain)).rejects.toThrow(
'Clickhouse query failed',
);
});
});

describe('getTrafficByProject()는', () => {
const domain = 'example.com';
const timeUnit = 'Hour';
Expand All @@ -207,7 +155,7 @@ describe('LogRepository 테스트', () => {
it('올바른 도메인과 시간 단위를 기준으로 트래픽 데이터를 반환해야 한다.', async () => {
mockClickhouse.query.mockResolvedValue(mockTrafficData);

const result = await repository.getTrafficByProject(domain, timeUnit);
const result = await repository.findTrafficByProject(domain, timeUnit);

expect(result).toEqual(mockTrafficData);
expect(clickhouse.query).toHaveBeenCalledWith(
Expand All @@ -221,7 +169,7 @@ describe('LogRepository 테스트', () => {
it('트래픽 데이터가 없을 경우 빈 배열을 반환해야 한다.', async () => {
mockClickhouse.query.mockResolvedValue([]);

const result = await repository.getTrafficByProject(domain, timeUnit);
const result = await repository.findTrafficByProject(domain, timeUnit);

expect(result).toEqual([]);
expect(clickhouse.query).toHaveBeenCalledWith(
Expand All @@ -234,7 +182,7 @@ describe('LogRepository 테스트', () => {
const error = new Error('Clickhouse query failed');
mockClickhouse.query.mockRejectedValue(error);

await expect(repository.getTrafficByProject(domain, timeUnit)).rejects.toThrow(
await expect(repository.findTrafficByProject(domain, timeUnit)).rejects.toThrow(
'Clickhouse query failed',
);

Expand Down Expand Up @@ -270,9 +218,9 @@ describe('LogRepository 테스트', () => {

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';
Expand All @@ -281,7 +229,7 @@ describe('LogRepository 테스트', () => {
const mockResult = [{ dau: 150 }];
mockClickhouse.query.mockResolvedValue(mockResult);

const result = await repository.getDAUByProject(domain, date);
const result = await repository.findDAUByProject(domain, date);

expect(result).toBe(150);
expect(clickhouse.query).toHaveBeenCalledWith(
Expand All @@ -295,7 +243,7 @@ describe('LogRepository 테스트', () => {
it('DAU 데이터가 없을 경우 0을 반환해야 한다.', async () => {
mockClickhouse.query.mockResolvedValue([]);

const result = await repository.getDAUByProject(domain, date);
const result = await repository.findDAUByProject(domain, date);

expect(result).toBe(0);
expect(clickhouse.query).toHaveBeenCalledWith(
Expand All @@ -310,7 +258,7 @@ describe('LogRepository 테스트', () => {
const mockResult = [{ dau: null }];
mockClickhouse.query.mockResolvedValue(mockResult);

const result = await repository.getDAUByProject(domain, date);
const result = await repository.findDAUByProject(domain, date);

expect(result).toBe(0);
expect(clickhouse.query).toHaveBeenCalledWith(
Expand All @@ -325,7 +273,7 @@ describe('LogRepository 테스트', () => {
const error = new Error('Clickhouse query failed');
mockClickhouse.query.mockRejectedValue(error);

await expect(repository.getDAUByProject(domain, date)).rejects.toThrow(
await expect(repository.findDAUByProject(domain, date)).rejects.toThrow(
'Clickhouse query failed',
);

Expand Down
21 changes: 17 additions & 4 deletions backend/console-server/src/log/log.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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()
Expand Down Expand Up @@ -139,7 +140,7 @@ export class LogRepository {
return results.map((result) => plainToInstance(ElapsedTimeByPathMetric, result));
}

async getSlowestPathsByDomain(domain: string) {
async findSlowestPathsByDomain(domain: string) {
const { query, params } = new TimeSeriesQueryBuilder()
.metrics([{ name: 'elapsed_time', aggregation: 'avg' }, { name: 'path' }])
.from('http_log')
Expand All @@ -154,7 +155,7 @@ export class LogRepository {
return results.map((result) => plainToInstance(ElapsedTimeByPathMetric, result));
}

async getTrafficByProject(domain: string, timeUnit: string) {
async findTrafficByProject(domain: string, timeUnit: string) {
const { query, params } = new TimeSeriesQueryBuilder()
.metrics([
{ name: '*', aggregation: 'count' },
Expand All @@ -171,18 +172,30 @@ export class LogRepository {
return results.map((result) => plainToInstance(TrafficCountMetric, result));
}

async getDAUByProject(domain: string, date: string) {
async findDAUByProject(domain: string, date: string) {
const { query, params } = new TimeSeriesQueryBuilder()
.metrics([{ name: `SUM(access) as dau` }])
.from('dau')
.filter({ domain: domain, date: date })
.build();

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<SpeedRankMetric>(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));
Expand Down
Loading

0 comments on commit a2f28eb

Please sign in to comment.