Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9de1ade
fix: add page
scopsy Jun 23, 2025
ddf9484
fix: commit
scopsy Jun 23, 2025
94c562a
fix: save
scopsy Jun 23, 2025
e42b0d9
fix: wip
scopsy Jun 23, 2025
e45ba83
fix: a
scopsy Jun 23, 2025
13774e0
fix: added
scopsy Jun 23, 2025
3c47a49
fix: wup
scopsy Jun 23, 2025
45555e3
Merge branch 'nv-6147-clickhouse-http-request-logging' into nv-6162-c…
djabarovgeorge Jun 23, 2025
25215cb
refactor(observability): remove Observability module and related comp…
djabarovgeorge Jun 23, 2025
07e41e2
feat(http-logs): enhance workflow runs content with filtering and imp…
djabarovgeorge Jun 24, 2025
31c3a99
fix(http-logs): replace load more indicator with icon for improved UI
djabarovgeorge Jun 24, 2025
d01f51b
refactor(http-logs): replace mock data with API integration for logs …
djabarovgeorge Jun 24, 2025
3f8ca83
refactor(http-logs): enhance workflow runs content with improved acti…
djabarovgeorge Jun 24, 2025
037a6f4
refactor(dashboard): update workflow runs filters and pagination logic
djabarovgeorge Jun 24, 2025
7a4ad98
refactor(logs): improve timestamp formatting in get-requests use case
djabarovgeorge Jun 24, 2025
6bf31f1
refactor(http-logs): streamline workflow runs content and filters
djabarovgeorge Jun 24, 2025
18c9caa
refactor(http-logs): enhance logs detail content layout
djabarovgeorge Jun 24, 2025
c0106e7
refactor(logs): clean up logs controller and enhance environment vari…
djabarovgeorge Jun 24, 2025
e593e27
refactor(logs): enhance GetRequestsDto validation and improve Clickho…
djabarovgeorge Jun 25, 2025
56e2f34
refactor(logs): update GetRequestsDto and command for type consistency
djabarovgeorge Jun 25, 2025
4e9d9d9
refactor(logs): update status code handling in GetRequests use case
djabarovgeorge Jun 25, 2025
3ceb773
refactor(logs): rename timestamp to createdAt for consistency across …
djabarovgeorge Jun 25, 2025
038bef0
refactor(logs): rename http_logs table to request_logs for clarity
djabarovgeorge Jun 25, 2025
7ed5b2a
refactor(logs): add empty state component for logs table
djabarovgeorge Jun 25, 2025
b9e9150
refactor(logs): update empty state components for logs
djabarovgeorge Jun 25, 2025
22cd8e7
refactor(logs): integrate cursor pagination into logs table
djabarovgeorge Jun 25, 2025
054edb2
refactor(logs): update GetRequests DTO and command for status code ha…
djabarovgeorge Jun 25, 2025
b664965
refactor(logs): enhance cursor pagination with last page functionality
djabarovgeorge Jun 25, 2025
8ec580a
refactor(logs): update GetRequests command and DTO for hours-based fi…
djabarovgeorge Jun 26, 2025
460e190
refactor(analytics): rename ANALYTICS_STRATEGY to AnalyticsStrategyEnum
djabarovgeorge Jun 26, 2025
77cc0dc
refactor(logs): transition from HttpLog to RequestLog for improved cl…
djabarovgeorge Jun 26, 2025
2c9788f
refactor(logs): remove onLast functionality from pagination components
djabarovgeorge Jun 27, 2025
a4f0380
Merge branch 'nv-6147-clickhouse-http-request-logging' into nv-6162-c…
djabarovgeorge Jun 29, 2025
2e6b19b
chore(dependencies): update pnpm lock
djabarovgeorge Jun 29, 2025
f5ba35a
Merge branch 'nv-6147-clickhouse-http-request-logging' into nv-6162-c…
djabarovgeorge Jun 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { MessagesModule } from './app/messages/messages.module';
import { NotificationGroupsModule } from './app/notification-groups/notification-groups.module';
import { NotificationModule } from './app/notifications/notification.module';
import { OrganizationModule } from './app/organization/organization.module';
import { ObservabilityModule } from './app/observability/observability.module';
import { LogsModule } from './app/logs/logs.module';
import { PartnerIntegrationsModule } from './app/partner-integrations/partner-integrations.module';
import { PreferencesModule } from './app/preferences';
import { ApiRateLimitInterceptor } from './app/rate-limiting/guards';
Expand Down Expand Up @@ -102,7 +102,7 @@ const baseModules: Array<Type | DynamicModule | Promise<DynamicModule> | Forward
NotificationGroupsModule,
ContentTemplatesModule,
OrganizationModule,
ObservabilityModule,
LogsModule,
UserModule,
IntegrationModule,
InternalModule,
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app/events/events.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { ThrottlerCategory, ThrottlerCost } from '../rate-limiting/guards';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { SdkGroupName, SdkMethodName, SdkUsageExample } from '../shared/framework/swagger/sdk.decorators';
import { KeylessAccessible } from '../shared/framework/swagger/keyless.security';
import { ANALYTICS_STRATEGY, LogAnalytics } from '../shared/framework/analytics-logs.interceptor';
import { AnalyticsStrategyEnum, LogAnalytics } from '../shared/framework/analytics-logs.interceptor';

@ThrottlerCategory(ApiRateLimitCategoryEnum.TRIGGER)
@ResourceCategory(ResourceEnum.EVENTS)
Expand All @@ -61,7 +61,7 @@ export class EventsController {
@KeylessAccessible()
@ExternalApiAccessible()
@Post('/trigger')
@LogAnalytics(ANALYTICS_STRATEGY.EVENTS)
@LogAnalytics(AnalyticsStrategyEnum.EVENTS)
@ApiResponse(TriggerEventResponseDto, 201)
@ApiResponse(PayloadValidationExceptionDto, 400, false, false, {
description: 'Payload validation failed - returned when payload does not match the workflow schema',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
ParseEventRequestCommand,
ParseEventRequestMulticastCommand,
} from './parse-event-request.command';
import { generateTransactionId } from '../../../shared/helpers';

@Injectable()
export class ParseEventRequest {
Expand All @@ -70,7 +71,7 @@ export class ParseEventRequest {

@InstrumentUsecase()
public async execute(command: ParseEventRequestCommand) {
const transactionId = command.transactionId || uuidv4();
const transactionId = command.transactionId || generateTransactionId();

const [environment, organization] = await Promise.all([
this.environmentRepository.findOne({ _id: command.environmentId }),
Expand Down
11 changes: 2 additions & 9 deletions apps/api/src/app/inbox/usecases/session/session.usecase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
UpsertControlValuesUseCase,
UpsertControlValuesCommand,
FeatureFlagsService,
generateTimestampHex,
} from '@novu/application-generic';
import {
CommunityOrganizationRepository,
Expand Down Expand Up @@ -359,7 +360,7 @@ export class Session {
const encryptedApiKey = encryptApiKey(key);
const hashedApiKey = createHash('sha256').update(key).digest('hex');

const encodedDate = dateToTimestampHex(new Date());
const encodedDate = generateTimestampHex();
const identifier = `${this.KEYLESS_ENVIRONMENT_PREFIX}${encodedDate}_${shortId(4)}`;
const environment = await this.environmentRepository.create({
_organizationId: organization._id,
Expand Down Expand Up @@ -711,14 +712,6 @@ export class Session {
}
}

function dateToTimestampHex(date) {
const timeInSeconds = Math.floor(date.getTime() / 1000);
const buffer = Buffer.alloc(4);
buffer.writeUInt32BE(timeInSeconds, 0);

return buffer.toString('hex');
}

function timestampHexToDate(timestampHex) {
if (!timestampHex || typeof timestampHex !== 'string' || timestampHex.length < 8) {
throw new Error('Invalid timestamp hex format');
Expand Down
72 changes: 72 additions & 0 deletions apps/api/src/app/logs/dtos/get-requests.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { IsNumber, IsOptional, IsString, Matches, MaxLength, Min, Max, IsArray } from 'class-validator';
import { Type, Transform } from 'class-transformer';

// Custom transformer to convert statusCode to array of numbers
const StatusCodeTransformer = Transform(({ value }) => {
if (!value) return undefined;

// If already an array of numbers, return as is
if (Array.isArray(value) && value.every((item) => typeof item === 'number')) {
return value;
}

// If array of strings/mixed, convert each to number
if (Array.isArray(value)) {
return value.map((item) => parseInt(String(item), 10)).filter((num) => !Number.isNaN(num));
}

// If string with comma-separated values
if (typeof value === 'string' && value.includes(',')) {
return value
.split(',')
.map((item) => parseInt(item.trim(), 10))
.filter((num) => !Number.isNaN(num));
}

// If single string or number
const num = parseInt(String(value), 10);

return Number.isNaN(num) ? undefined : [num];
});

export class GetRequestsDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
@Min(0)
@Max(100)
page?: number;

@IsNumber()
@IsOptional()
@Type(() => Number)
@Min(1)
@Max(100)
limit?: number;

@IsOptional()
@IsArray()
@IsNumber({}, { each: true })
@StatusCodeTransformer
statusCodes?: number[];

@IsString()
@IsOptional()
@MaxLength(500)
@Matches(/^[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=%]*$/, {
message: 'URL contains invalid characters',
})
url?: string;

@IsString()
@IsOptional()
@MaxLength(100)
transactionId?: string;

@IsNumber()
@IsOptional()
@Type(() => Number)
@Min(1)
@Max(2160) // 90 days * 24 hours
created?: number;
}
27 changes: 27 additions & 0 deletions apps/api/src/app/logs/dtos/get-requests.response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type RequestLogResponseDto = {
id: string;
createdAt: string;
url: string;
urlPattern: string;
method: string;
statusCode: number;
path: string;
hostname: string;
transactionId: string | null;
ip: string;
userAgent: string;
requestBody: string;
responseBody: string;
userId: string;
organizationId: string;
environmentId: string;
schemaType: string;
durationMs: number;
};

export type GetRequestsResponseDto = {
data: RequestLogResponseDto[];
total: number;
pageSize?: number;
page?: number;
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe.skip('Observability - /observability/logs/http-requests (GET) #novu-v2'
});

it('should return a list of http logs', async () => {
const { body } = await session.testAgent.get('/v1/observability/logs/http-requests').expect(200);
const { body } = await session.testAgent.get('/v1/logs/requests').expect(200);

expect(body.data).to.be.an('array');
expect(body.total).to.be.a('number');
Expand Down
31 changes: 31 additions & 0 deletions apps/api/src/app/logs/logs.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ClassSerializerInterceptor, Controller, Get, Query, UseInterceptors } from '@nestjs/common';
import { ExternalApiAccessible, UserSession } from '@novu/application-generic';
import { GetRequests } from './usecases/get-requests/get-requests.usecase';
import { GetRequestsCommand } from './usecases/get-requests/get-requests.command';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { GetRequestsDto } from './dtos/get-requests.dto';
import { GetRequestsResponseDto } from './dtos/get-requests.response.dto';

@Controller('/logs')
@UseInterceptors(ClassSerializerInterceptor)
@RequireAuthentication()
export class LogsController {
constructor(private getRequestsUsecase: GetRequests) {}

@Get('requests')
@ExternalApiAccessible()
async getLogs(
@UserSession() user,
@Query()
query: GetRequestsDto
): Promise<GetRequestsResponseDto> {
const command = GetRequestsCommand.create({
organizationId: user.organizationId,
userId: user._id,
hoursAgo: query.created,
...query,
});

return this.getRequestsUsecase.execute(command);
}
}
13 changes: 13 additions & 0 deletions apps/api/src/app/logs/logs.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { LogsController } from './logs.controller';
import { GetRequests } from './usecases/get-requests/get-requests.usecase';
import { SharedModule } from '../shared/shared.module';

const USE_CASES = [GetRequests];

@Module({
imports: [SharedModule],
controllers: [LogsController],
providers: [...USE_CASES],
})
export class LogsModule {}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { IsArray, IsNumber, IsOptional, IsString } from 'class-validator';
import { OrganizationCommand } from '@novu/application-generic';

export class GetHttpLogsCommand extends OrganizationCommand {
export class GetRequestsCommand extends OrganizationCommand {
@IsNumber()
@IsOptional()
public page?: number;
Expand All @@ -10,9 +10,10 @@ export class GetHttpLogsCommand extends OrganizationCommand {
@IsOptional()
public limit?: number;

@IsString()
@IsOptional()
public statusCode?: string;
@IsArray()
@IsNumber({}, { each: true })
statusCodes?: number[];

@IsString()
@IsOptional()
Expand All @@ -22,7 +23,7 @@ export class GetHttpLogsCommand extends OrganizationCommand {
@IsOptional()
public transactionId?: string;

@IsString()
@IsNumber()
@IsOptional()
public days?: string;
public hoursAgo?: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { Injectable } from '@nestjs/common';
import { subHours } from 'date-fns';
import { RequestLog, RequestLogRepository, Where } from '@novu/application-generic';
import { GetRequestsCommand } from './get-requests.command';
import { GetRequestsResponseDto, RequestLogResponseDto } from '../../dtos/get-requests.response.dto';

@Injectable()
export class GetRequests {
constructor(private readonly requestLogRepository: RequestLogRepository) {}

async execute(command: GetRequestsCommand): Promise<GetRequestsResponseDto> {
const limit = command.limit || 10;
const page = command.page || 0;
const offset = page * limit;

const where: Where<RequestLog> = {
organization_id: command.organizationId,
};

if (command.statusCodes) {
where.status_code = {
operator: 'IN',
value: command.statusCodes,
};
}

if (command.url) {
where.url = { operator: 'LIKE', value: `%${command.url}%` };
}

if (command.transactionId) {
where.transaction_id = command.transactionId;
}

if (command.hoursAgo) {
where.created_at = {
operator: '>=',
value: subHours(new Date(), command.hoursAgo).toISOString().slice(0, 19).replace('T', ' ') as any,
};
}

const [findResult, total] = await Promise.all([
this.requestLogRepository.find({
where,
limit,
offset,
orderBy: 'created_at',
orderDirection: 'DESC',
}),
this.requestLogRepository.count({ where }),
]);

const mappedData: RequestLogResponseDto[] = findResult.data.map((log) => ({
id: log.id,
createdAt: new Date(`${log.created_at} UTC`).toISOString(),
url: log.url,
urlPattern: log.url_pattern,
method: log.method,
path: log.path,
statusCode: log.status_code,
hostname: log.hostname,
transactionId: log.transaction_id,
ip: log.ip,
userAgent: log.user_agent,
requestBody: log.request_body,
responseBody: log.response_body,
userId: log.user_id,
organizationId: log.organization_id,
environmentId: log.environment_id,
schemaType: log.schema_type,
durationMs: log.duration_ms,
}));

return {
data: mappedData,
total,
pageSize: limit,
page,
};
}
}
14 changes: 0 additions & 14 deletions apps/api/src/app/observability/dtos/get-http-logs-response.dto.ts

This file was deleted.

Loading
Loading