From 4e611b502b7653e24eaec00e09ac98824eca52d8 Mon Sep 17 00:00:00 2001 From: Daniel van Ginneken Date: Tue, 16 Dec 2025 15:53:42 +0100 Subject: [PATCH] feat(logging): implement custom logging service and middleware for enhanced request and error logging --- src/app.module.ts | 7 +- src/application/services/auth.service.ts | 23 +++++- src/common/logger.service.ts | 53 +++++++++++++ src/infrastructure/database/prisma.ts | 76 ++++++++++++++++++- src/main.ts | 9 ++- .../middleware/request-logging.middleware.ts | 23 ++++++ 6 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 src/common/logger.service.ts create mode 100644 src/presentation/middleware/request-logging.middleware.ts diff --git a/src/app.module.ts b/src/app.module.ts index 2a5ba73..8914d57 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,10 +6,13 @@ import { AuthController } from './presentation/controllers/auth.controller'; import { UserController } from './presentation/controllers/user.controller'; import { UserRepository } from './infrastructure/database/repositories/user.repository'; import { SessionActivityMiddleware } from './presentation/middleware/session-activity.middleware'; +import { LoggerService } from './common/logger.service'; +import { RequestLoggingMiddleware } from './presentation/middleware/request-logging.middleware'; @Module({ imports: [], providers: [ + LoggerService, PrismaService, { provide: 'SERVICE.AUTH', @@ -28,6 +31,8 @@ import { SessionActivityMiddleware } from './presentation/middleware/session-act }) export class AppModule implements NestModule { configure(consumer: MiddlewareConsumer) { - consumer.apply(SessionActivityMiddleware).forRoutes('*'); + consumer + .apply(RequestLoggingMiddleware, SessionActivityMiddleware) + .forRoutes('*'); } } diff --git a/src/application/services/auth.service.ts b/src/application/services/auth.service.ts index 1628b5a..b22a1f1 100644 --- a/src/application/services/auth.service.ts +++ b/src/application/services/auth.service.ts @@ -2,6 +2,7 @@ import { type IUserRepository } from '@/domain/user/user-repository.interface'; import { IAuthService } from '@/application/ports/auth.port'; import { verify, type Options } from '@node-rs/argon2'; import { Inject, Injectable } from '@nestjs/common'; +import { LoggerService } from '@/common/logger.service'; import { fail, succeed } from '@/result'; @Injectable() @@ -14,18 +15,34 @@ export class AuthService implements IAuthService { timeCost: 2, }; - constructor(@Inject('REPO.USER') _userRepository: IUserRepository) { + constructor( + @Inject('REPO.USER') _userRepository: IUserRepository, + private readonly logger?: LoggerService, + ) { this.userRepository = _userRepository; } async login(email: string, password: string) { const result = await this.userRepository.findByEmail(email); - if (result._tag === 'Failure') return fail(result.error); + if (result._tag === 'Failure') { + // Don't log repository-specific "User not found" to avoid exposing user existence. + const repoMsg = result.error?.message ?? 'Unknown error'; + const logMsg = + repoMsg === 'User not found' ? 'Credentials not correct' : repoMsg; + this.logger?.warn( + `Failed login attempt for ${email}: ${logMsg}`, + 'AuthService', + ); + return fail(result.error); + } const user = result.data; const isMatch = await verify(user.hashedPassword, password); - if (!isMatch) return fail(new Error('Invalid credentials')); + if (!isMatch) { + this.logger?.warn(`Invalid credentials for ${email}`, 'AuthService'); + return fail(new Error('Invalid credentials')); + } return succeed({ user }); } diff --git a/src/common/logger.service.ts b/src/common/logger.service.ts new file mode 100644 index 0000000..a3dcdb5 --- /dev/null +++ b/src/common/logger.service.ts @@ -0,0 +1,53 @@ +import { Injectable, LoggerService as NestLoggerService } from '@nestjs/common'; + +type LogLevel = 'log' | 'error' | 'warn' | 'debug' | 'verbose'; + +@Injectable() +export class LoggerService implements NestLoggerService { + private name = 'App'; + + setContext(name: string) { + this.name = name; + } + + private format(level: LogLevel, message: any, context?: string, meta?: any) { + const payload: Record = { + timestamp: new Date().toISOString(), + level, + context: context ?? this.name, + message: typeof message === 'string' ? message : JSON.stringify(message), + }; + + if (meta) payload.meta = meta; + return JSON.stringify(payload); + } + + log(message: any, context?: string) { + // Info + // eslint-disable-next-line no-console + console.info(this.format('log', message, context)); + } + + error(message: any, trace?: string, context?: string) { + const meta = trace ? { trace } : undefined; + // eslint-disable-next-line no-console + console.error(this.format('error', message, context, meta)); + } + + warn(message: any, context?: string) { + // eslint-disable-next-line no-console + console.warn(this.format('warn', message, context)); + } + + debug?(message: any, context?: string) { + // eslint-disable-next-line no-console + console.debug(this.format('debug', message, context)); + } + + verbose?(message: any, context?: string) { + // eslint-disable-next-line no-console + console.debug(this.format('verbose', message, context)); + } +} + +export default LoggerService; diff --git a/src/infrastructure/database/prisma.ts b/src/infrastructure/database/prisma.ts index f99ce26..a315247 100644 --- a/src/infrastructure/database/prisma.ts +++ b/src/infrastructure/database/prisma.ts @@ -2,23 +2,91 @@ import 'dotenv/config'; import { PrismaMariaDb } from '@prisma/adapter-mariadb'; import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@/infrastructure/database/generated/prisma/client'; +import { LoggerService } from '@/common/logger.service'; @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { - constructor() { - const adapter = new PrismaMariaDb(process.env.DATABASE_URL!); + constructor(private readonly logger: LoggerService) { + // Parse DATABASE_URL and create mariadb pool config + const dbUrl = new URL(process.env.DATABASE_URL!); + const adapter = new PrismaMariaDb({ + host: dbUrl.hostname, + port: parseInt(dbUrl.port || '3306', 10), + user: dbUrl.username, + password: dbUrl.password, + database: dbUrl.pathname.slice(1), // remove leading / + connectionLimit: 20, + connectTimeout: 30000, + }); super({ adapter }); + + // Forward Prisma client events to our logger + try { + // Prisma client's $on typing may be strict depending on generated client. + // Cast to `any` to attach listeners at runtime while keeping TypeScript happy. + (this as any).$on('info', (e: any) => { + this.logger.log(`${e.message}`, 'Prisma'); + }); + + (this as any).$on('warn', (e: any) => { + this.logger.warn(`${e.message}`, 'Prisma'); + }); + + (this as any).$on('error', (e: any) => { + // e is an object with message and perhaps stack + this.logger.error( + `${e.message}`, + (e as any).stack ?? undefined, + 'Prisma', + ); + }); + } catch (e) { + // Listening may fail in some runtime builds; safely ignore + } } async onModuleInit() { - await this.$connect(); + const maxRetries = 5; + const delayMs = 2000; + let attempt = 0; + while (attempt < maxRetries) { + try { + await this.$connect(); + this.logger.log('Connected to database', 'PrismaService'); + return; + } catch (err) { + attempt += 1; + const msg = `Database connect attempt ${attempt} failed: ${ + err instanceof Error ? err.message : String(err) + }`; + this.logger.warn(msg, 'PrismaService'); + if (attempt >= maxRetries) { + this.logger.error( + 'Failed to connect to database after retries', + err instanceof Error ? err.stack : String(err), + 'PrismaService', + ); + throw err; + } + // wait before retrying + await new Promise((r) => setTimeout(r, delayMs)); + } + } } async onModuleDestroy() { - await this.$disconnect(); + try { + await this.$disconnect(); + this.logger.log('Disconnected from database', 'PrismaService'); + } catch (e) { + this.logger.warn( + `Error during disconnect: ${e instanceof Error ? e.message : String(e)}`, + 'PrismaService', + ); + } } } diff --git a/src/main.ts b/src/main.ts index b3398d5..5cf7bd9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { LoggerService } from './common/logger.service'; import cookieParser from 'cookie-parser'; import session from 'express-session'; import helmet from 'helmet'; @@ -10,8 +11,10 @@ import { configDotenv } from 'dotenv'; configDotenv(); async function bootstrap() { - const logger = new Logger('Bootstrap'); const app = await NestFactory.create(AppModule); + const logger = app.get(LoggerService); + // Let Nest use our LoggerService for framework logs and exceptions + app.useLogger(logger); const isProduction = process.env.NODE_ENV === 'production'; // Global API prefix - must be set before middleware @@ -80,7 +83,7 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('docs', app, document); - logger.log('Swagger UI initialized at /docs'); + logger.log('Swagger UI initialized at /docs', 'Bootstrap'); } // Graceful Shutdown @@ -88,7 +91,7 @@ async function bootstrap() { const port = process.env.PUBLIC_PORT ?? 4000; await app.listen(port); - logger.log(`Application is running on: ${await app.getUrl()}`); + logger.log(`Application is running on: ${await app.getUrl()}`, 'Bootstrap'); } bootstrap().catch((err) => { diff --git a/src/presentation/middleware/request-logging.middleware.ts b/src/presentation/middleware/request-logging.middleware.ts new file mode 100644 index 0000000..4b6e9ad --- /dev/null +++ b/src/presentation/middleware/request-logging.middleware.ts @@ -0,0 +1,23 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { LoggerService } from '../../common/logger.service'; + +@Injectable() +export class RequestLoggingMiddleware implements NestMiddleware { + constructor(private readonly logger: LoggerService) {} + + use(req: Request, res: Response, next: NextFunction) { + const start = Date.now(); + const { method, originalUrl } = req; + + res.on('finish', () => { + const duration = Date.now() - start; + const message = `${method} ${originalUrl} ${res.statusCode} ${duration}ms`; + this.logger.log(message, 'HTTP'); + }); + + next(); + } +} + +export default RequestLoggingMiddleware;