Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c2b6911
feat(api-service): enhance analytics database setup and logging (#8658)
djabarovgeorge Jul 8, 2025
25ba804
feat(github-actions): add ClickHouse startup step to project setup
djabarovgeorge Jul 8, 2025
b205f64
feat(github-actions): add environment variables for ClickHouse config…
djabarovgeorge Jul 8, 2025
7472d2b
feat(api-service): update ClickHouse integration and cleanup functions
djabarovgeorge Jul 9, 2025
75b6694
refactor(github-actions): streamline ClickHouse startup configuration
djabarovgeorge Jul 9, 2025
0504ecb
feat(api-service): add E2E tests for activity traces in notifications
djabarovgeorge Jul 9, 2025
9a3b171
refactor(e2e-setup): enhance database connection management and clean…
djabarovgeorge Jul 10, 2025
c07b844
refactor(e2e-setup): simplify test server initialization in setup
djabarovgeorge Jul 10, 2025
76e5ee9
feat(analytic-logs): implement log repository and enhance ClickHouse …
djabarovgeorge Jul 10, 2025
5fcfdd3
refactor(e2e-setup): streamline ClickHouse database management in setup
djabarovgeorge Jul 10, 2025
e52c8f4
fix: test
djabarovgeorge Jul 10, 2025
34b4378
fix(e2e-setup): revert
djabarovgeorge Jul 10, 2025
7c70775
Merge branch 'next' into create-trace-e2e
djabarovgeorge Jul 14, 2025
ccf1218
refactor(clickhouse): remove unused logger imports and commented out …
djabarovgeorge Jul 15, 2025
ada517b
refactor(e2e-setup): remove unused logger instantiation in ClickHouse…
djabarovgeorge Jul 15, 2025
5560c3a
Merge branch 'next' into create-trace-e2e
djabarovgeorge Jul 16, 2025
c2558c4
fix(api): test
djabarovgeorge Jul 16, 2025
cd31813
fix(clickhouse): allow ClickHouse client to connect in test environment
djabarovgeorge Jul 16, 2025
352cabd
fix(workflows): run worker service in the background during E2E tests
djabarovgeorge Jul 16, 2025
eb7ad9e
fix(api-service): test setup
djabarovgeorge Jul 16, 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
8 changes: 8 additions & 0 deletions .github/actions/setup-project/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ runs:
with:
mongodb-version: 8.0

- name: 🔍 Start ClickHouse
if: ${{ inputs.slim == 'false' }}
uses: praneeth527/clickhouse-server-action@v1.0.0
env:
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
with:
tag: '24.3-alpine'

- name: 🛟 Install dependencies
shell: bash
run: pnpm install --frozen-lockfile
Expand Down
131 changes: 119 additions & 12 deletions apps/api/e2e/setup.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,156 @@
/* eslint-disable no-console */
import { testServer } from '@novu/testing';
import sinon from 'sinon';
import chai from 'chai';
import { Connection } from 'mongoose';
import { DalService } from '@novu/dal';
import { ClickHouseClient, ClickHouseService, createClickHouseClient, PinoLogger } from '@novu/application-generic';
import { bootstrap } from '../src/bootstrap';

let connection: Connection;
let databaseConnection: Connection;
let analyticsConnection: ClickHouseClient | undefined;
let clickHouseService: ClickHouseService | undefined;
const dalService = new DalService();

async function getConnection() {
if (!connection) {
connection = await dalService.connect(process.env.MONGO_URL);
async function getDatabaseConnection(): Promise<Connection> {
if (!databaseConnection) {
databaseConnection = await dalService.connect(process.env.MONGO_URL);
}

return connection;
return databaseConnection;
}

async function dropDatabase() {
async function dropDatabase(): Promise<void> {
try {
const conn = await getConnection();
const conn = await getDatabaseConnection();
await conn.db.dropDatabase();
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error dropping the database:', error);
}
}

async function closeDatabaseConnection(): Promise<void> {
if (databaseConnection) {
await databaseConnection.close();
}
}

async function getClickHouseConnection(): Promise<ClickHouseClient | undefined> {
if (!analyticsConnection) {
if (!clickHouseService) {
clickHouseService = new ClickHouseService(new PinoLogger({}));
await clickHouseService.init();
}
analyticsConnection = clickHouseService?.client;
}

return analyticsConnection;
}

function createClickHouseTestClient(database?: string): ClickHouseClient {
return createClickHouseClient({
host: 'http://localhost:8123',
username: 'default',
password: '',
database: database || 'default',
});
}

async function ensureClickHouseDatabase(databaseName: string): Promise<void> {
try {
const client = createClickHouseTestClient('default');
await client.query({
query: `CREATE DATABASE IF NOT EXISTS ${databaseName}`,
});
console.log(`Database "${databaseName}" ensured.`);
} catch (error) {
console.log(`Failed to create database ${databaseName}:`, error.message);
}
}

async function getClickHouseTables(databaseName: string): Promise<string[]> {
try {
const conn = await getClickHouseConnection();
if (!conn) return [];

const result = await conn.query({
query: `SHOW TABLES FROM ${databaseName}`,
format: 'JSONEachRow',
});

const tables = (await result.json()) as Array<{ name: string }>;

return tables.map((t) => t.name);
} catch (error) {
console.log(`Could not query tables in ${databaseName}: ${error.message}`);

return [];
}
}

async function truncateClickHouseTable(databaseName: string, tableName: string): Promise<void> {
try {
const conn = await getClickHouseConnection();
if (!conn) return;

await conn.exec({ query: `TRUNCATE TABLE IF EXISTS ${databaseName}.${tableName}` });
console.log(`Successfully cleaned table ${tableName}`);
} catch (error) {
console.log(`Failed to clean table ${tableName}:`, error.message);
}
}

async function cleanupClickHouseDatabase(): Promise<void> {
try {
const databaseName = process.env.CLICK_HOUSE_DATABASE || 'test_logs';
console.log(`Cleaning up ClickHouse database: ${databaseName}`);

await ensureClickHouseDatabase(databaseName);

const tables = await getClickHouseTables(databaseName);
if (tables.length > 0) {
console.log(`Found ${tables.length} tables: ${tables.join(', ')}`);
await Promise.all(tables.map((table) => truncateClickHouseTable(databaseName, table)));
console.log(`Cleaned up ${tables.length} tables in ${databaseName}`);
} else {
console.log(`No tables to clean up in ${databaseName}`);
}

console.log(`ClickHouse database ${databaseName} cleanup completed`);
} catch (error) {
console.log('Analytics database cleanup encountered an issue:', error.message);
console.log('This is acceptable for test environment - continuing with test setup');
}
}

async function closeClickHouseConnection(): Promise<void> {
if (analyticsConnection) {
await analyticsConnection.close();
}
if (clickHouseService) {
await clickHouseService.onModuleDestroy();
}
}

before(async () => {
/**
* disable truncating for better error messages - https://www.chaijs.com/guide/styles/#configtruncatethreshold
*/
chai.config.truncateThreshold = 0;

await dropDatabase();
await cleanupClickHouseDatabase();
await testServer.create((await bootstrap()).app);
});

after(async () => {
await testServer.teardown();
await dropDatabase();
if (connection) {
await connection.close();
}
await cleanupClickHouseDatabase();
await closeDatabaseConnection();
await closeClickHouseConnection();
});

afterEach(async function () {
afterEach(async () => {
sinon.restore();
});
8 changes: 7 additions & 1 deletion apps/api/src/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ MAX_NOVU_INTEGRATION_MAIL_REQUESTS=300
INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY=
NOVU_EMAIL_INTEGRATION_API_KEY=test

LOG_LEVEL=error
LOG_LEVEL=warn

LAUNCH_DARKLY_SDK_KEY=

Expand Down Expand Up @@ -153,3 +153,9 @@ PLAIN_IDENTITY_VERIFICATION_SECRET_KEY='PLAIN_IDENTITY_VERIFICATION_SECRET_KEY'
NOVU_INTERNAL_SECRET_KEY=test
KEYLESS_ORGANIZATION_ID=67b89421f8bd757ea40f39ab
KEYLESS_USER_EMAIL=67b89421f8bd757ea40f39ab


CLICK_HOUSE_URL=http://localhost:8123
CLICK_HOUSE_USER=default
CLICK_HOUSE_PASSWORD=
CLICK_HOUSE_DATABASE=test_logs
192 changes: 192 additions & 0 deletions apps/api/src/app/logs/e2e/get-requests.e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { UserSession } from '@novu/testing';
import { expect } from 'chai';
import { format, subHours, isBefore, isAfter } from 'date-fns';
import { Novu } from '@novu/api';
import { LogRepository, RequestLog, RequestLogRepository } from '@novu/application-generic';
import { initNovuClassSdk } from '../../shared/helpers/e2e/sdk/e2e-sdk.helper';
import { generateTransactionId } from '../../shared/helpers';
import { mapRequestLogToResponseDto } from '../shared/mappers';
import { RequestLogResponseDto } from '../dtos/get-requests.response.dto';

describe('Logs - /logs/requests (GET) #novu-v2', () => {
let session: UserSession;
let novuClient: Novu;
let requestLogRepository: RequestLogRepository;

beforeEach(async () => {
session = new UserSession();
await session.initialize();
novuClient = initNovuClassSdk(session);
requestLogRepository = session.testServer?.getService(RequestLogRepository);
});

it('should return a list of http logs', async () => {
const requestLog: Omit<RequestLog, 'id' | 'expires_at'> = {
user_id: session.user._id,
environment_id: session.environment._id,
organization_id: session.organization._id,
transaction_id: generateTransactionId(),
status_code: 200,
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss') as any,
path: '/test-path',
url: '/test-url',
url_pattern: '/test-url-pattern',
hostname: 'localhost',
method: 'GET',
ip: '127.0.0.1',
user_agent: 'test-agent',
request_body: '{}',
response_body: '{}',
auth_type: 'ApiKey',
duration_ms: 42,
};

await requestLogRepository.insert(requestLog);
await requestLogRepository.insert(requestLog);

const { body } = await session.testAgent.get('/v1/logs/requests').expect(200);

expect(body.data.length).to.be.equal(2);
expect(body.total).to.be.equal(2);
expect(body.pageSize).to.be.equal(10);

const expectedLog = normalizeRequestLogForTesting(mapRequestLogToResponseDto(requestLog as RequestLog));
const responseLog = normalizeRequestLogForTesting(body.data[0]);
expect(responseLog).to.deep.equal(expectedLog);
});

it('should filter http logs by url, transaction id, and created time', async () => {
const baseRequestLog: Omit<RequestLog, 'id' | 'expires_at' | 'status_code' | 'url'> = {
user_id: session.user._id,
environment_id: session.environment._id,
organization_id: session.organization._id,
transaction_id: generateTransactionId(),
created_at: format(new Date(), 'yyyy-MM-dd HH:mm:ss') as any,
path: '/test-path',
url_pattern: '/test-url-pattern',
hostname: 'localhost',
method: 'GET',
ip: '127.0.0.1',
user_agent: 'test-agent',
request_body: '{}',
response_body: '{}',
auth_type: 'ApiKey',
duration_ms: 42,
};

// Create logs with different status codes, URLs, transaction IDs, and timestamps
const transactionId1 = generateTransactionId();
const transactionId2 = generateTransactionId();
const currentTime = new Date();
const threeHoursAgo = subHours(currentTime, 3);

const log200Api = {
...baseRequestLog,
status_code: 200,
url: '/api/workflows',
transaction_id: transactionId1,
created_at: LogRepository.formatDateTime64(currentTime) as any,
};
const log404Api = {
...baseRequestLog,
status_code: 404,
url: '/api/notifications',
transaction_id: transactionId1,
created_at: LogRepository.formatDateTime64(currentTime) as any,
};
const log500Api = {
...baseRequestLog,
status_code: 500,
url: '/api/users',
transaction_id: transactionId2,
created_at: LogRepository.formatDateTime64(threeHoursAgo) as any,
};
const log200Auth = {
...baseRequestLog,
status_code: 200,
url: '/auth/login',
transaction_id: transactionId2,
created_at: LogRepository.formatDateTime64(threeHoursAgo) as any,
};

await requestLogRepository.insert(log200Api);
await requestLogRepository.insert(log404Api);
await requestLogRepository.insert(log500Api);
await requestLogRepository.insert(log200Auth);

// Test 1: Filter by status codes 200 and 404
const statusFilterResponse = await session.testAgent
.get('/v1/logs/requests')
.query({ statusCodes: [200, 404] })
.expect(200);

expect(statusFilterResponse.body.data.length).to.be.equal(3);
expect(statusFilterResponse.body.total).to.be.equal(3);

const statusCodes = statusFilterResponse.body.data.map((log: RequestLogResponseDto) => log.statusCode);
expect(statusCodes.length).to.be.equal(3);
expect(statusCodes).to.include.members([200, 404]);

// Test 2: Filter by URL containing 'api'
const urlFilterResponse = await session.testAgent.get('/v1/logs/requests').query({ url: 'api' }).expect(200);

expect(urlFilterResponse.body.data.length).to.be.equal(3);
expect(urlFilterResponse.body.total).to.be.equal(3);

const urls = urlFilterResponse.body.data.map((log: RequestLogResponseDto) => log.url);
urls.forEach((url: string) => {
expect(url).to.include('api');
});

// Test 3: Combine filters - status codes 200,404 AND URL containing 'workflows'
const combinedFilterResponse = await session.testAgent
.get('/v1/logs/requests')
.query({ statusCodes: [200, 404], url: 'workflows' })
.expect(200);

expect(combinedFilterResponse.body.data.length).to.be.equal(1);
expect(combinedFilterResponse.body.total).to.be.equal(1);

const combinedResult = combinedFilterResponse.body.data[0];
expect(combinedResult.statusCode).to.be.equal(200);
expect(combinedResult.url).to.include('workflows');

// Test 4: Filter by transaction ID
const transactionFilterResponse = await session.testAgent
.get('/v1/logs/requests')
.query({ transactionId: transactionId1 })
.expect(200);

expect(transactionFilterResponse.body.data.length).to.be.equal(2);
expect(transactionFilterResponse.body.total).to.be.equal(2);

const transactionIds = transactionFilterResponse.body.data.map((log: RequestLogResponseDto) => log.transactionId);
transactionIds.forEach((txId: string) => {
expect(txId).to.be.equal(transactionId1);
});

// Verify the correct logs are returned for transactionId1
const returnedStatusCodes = transactionFilterResponse.body.data.map((log: RequestLogResponseDto) => log.statusCode);
expect(returnedStatusCodes).to.include.members([200, 404]);

// Test 5: Filter by created (last 2 hours) - should only return recent logs
const createdFilterResponse = await session.testAgent.get('/v1/logs/requests').query({ created: 2 }).expect(200);

expect(createdFilterResponse.body.data.length).to.be.equal(2);
expect(createdFilterResponse.body.total).to.be.equal(2);

// Verify only recent logs (within last 2 hours) are returned
const recentCreatedAt = createdFilterResponse.body.data.map(
(log: RequestLogResponseDto) => new Date(log.createdAt)
);
const twoHoursAgo = subHours(currentTime, 2);
expect(isAfter(recentCreatedAt[0], twoHoursAgo)).to.be.true;
expect(isAfter(recentCreatedAt[1], twoHoursAgo)).to.be.true;
});
});

function normalizeRequestLogForTesting(requestLog: RequestLogResponseDto): Omit<RequestLogResponseDto, 'id'> {
const { id, ...rest } = requestLog;

return rest;
}
Loading
Loading