From 06c5b1768e09834d0adaa1fa9b3131d309c60572 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Tue, 9 Jun 2026 14:09:34 +0200 Subject: [PATCH] logging --- listify-api/.env.docker.example | 5 +- listify-api/.env.example | 5 +- listify-api/src/app.module.ts | 51 ++++--- listify-api/src/database/data-source.ts | 10 +- .../src/database/database-logging.config.ts | 53 ++++++++ listify-api/src/database/database.logger.ts | 126 ++++++++++++++++++ 6 files changed, 231 insertions(+), 19 deletions(-) create mode 100644 listify-api/src/database/database-logging.config.ts create mode 100644 listify-api/src/database/database.logger.ts diff --git a/listify-api/.env.docker.example b/listify-api/.env.docker.example index 5d99bcf..a85eeb1 100644 --- a/listify-api/.env.docker.example +++ b/listify-api/.env.docker.example @@ -7,7 +7,10 @@ DB_PORT=3306 DB_USERNAME=listify DB_PASSWORD=change-me DB_DATABASE=listify -DB_LOGGING=false +DB_LOGGING=all +DB_LOG_PARAMETERS=false +DB_LOG_MAX_PARAM_LENGTH=500 +DB_SLOW_QUERY_THRESHOLD_MS=500 JWT_ACCESS_SECRET=change-me-access-secret JWT_REFRESH_SECRET=change-me-refresh-secret diff --git a/listify-api/.env.example b/listify-api/.env.example index 7b85c25..ea4bc28 100644 --- a/listify-api/.env.example +++ b/listify-api/.env.example @@ -5,7 +5,10 @@ DB_PORT=3306 DB_USERNAME=listify DB_PASSWORD=change-me DB_DATABASE=listify -DB_LOGGING=false +DB_LOGGING=all +DB_LOG_PARAMETERS=false +DB_LOG_MAX_PARAM_LENGTH=500 +DB_SLOW_QUERY_THRESHOLD_MS=500 JWT_ACCESS_SECRET=change-me-access-secret JWT_REFRESH_SECRET=change-me-refresh-secret diff --git a/listify-api/src/app.module.ts b/listify-api/src/app.module.ts index 4b682e4..251e297 100644 --- a/listify-api/src/app.module.ts +++ b/listify-api/src/app.module.ts @@ -8,23 +8,46 @@ import { AuthModule } from './auth/auth.module'; import { ListTemplatesModule } from './list-templates/list-templates.module'; import { ListsModule } from './lists/lists.module'; import { MailModule } from './mail/mail.module'; +import { + databaseLoggerOptionsFromEnv, + parseDatabaseLogging, + slowQueryThresholdFromEnv, +} from './database/database-logging.config'; +import { DatabaseLogger } from './database/database.logger'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), TypeOrmModule.forRootAsync({ inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - type: 'mysql', - host: configService.get('DB_HOST', 'localhost'), - port: Number(configService.get('DB_PORT', '3306')), - username: configService.get('DB_USERNAME', 'root'), - password: configService.get('DB_PASSWORD', ''), - database: configService.get('DB_DATABASE', 'listify'), - autoLoadEntities: true, - synchronize: false, - logging: configService.get('DB_LOGGING', 'false') === 'true', - }), + useFactory: (configService: ConfigService) => { + const env = { + DB_LOGGING: configService.get('DB_LOGGING', 'false'), + DB_LOG_PARAMETERS: configService.get('DB_LOG_PARAMETERS', 'false'), + DB_LOG_MAX_PARAM_LENGTH: configService.get( + 'DB_LOG_MAX_PARAM_LENGTH', + '500', + ), + DB_SLOW_QUERY_THRESHOLD_MS: configService.get( + 'DB_SLOW_QUERY_THRESHOLD_MS', + '500', + ), + }; + + return { + type: 'mysql', + host: configService.get('DB_HOST', 'localhost'), + port: Number(configService.get('DB_PORT', '3306')), + username: configService.get('DB_USERNAME', 'root'), + password: configService.get('DB_PASSWORD', ''), + database: configService.get('DB_DATABASE', 'listify'), + autoLoadEntities: true, + synchronize: false, + logging: parseDatabaseLogging(env.DB_LOGGING), + logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(env)), + maxQueryExecutionTime: slowQueryThresholdFromEnv(env), + }; + }, }), EventEmitterModule.forRoot(), AuthModule, @@ -35,8 +58,4 @@ import { MailModule } from './mail/mail.module'; controllers: [AppController], providers: [AppService], }) -export class AppModule { - constructor(configService: ConfigService) { - console.log(configService.get('DB_HOST', 'localhost')) - } -} +export class AppModule {} diff --git a/listify-api/src/database/data-source.ts b/listify-api/src/database/data-source.ts index c2f15ac..620ee22 100644 --- a/listify-api/src/database/data-source.ts +++ b/listify-api/src/database/data-source.ts @@ -8,6 +8,12 @@ import { ListTemplateItemEntity } from '../list-templates/list-template-item.ent import { TemplateSeedEntity } from '../list-templates/template-seed.entity'; import { UserListEntity } from '../lists/user-list.entity'; import { UserListItemEntity } from '../lists/user-list-item.entity'; +import { + databaseLoggerOptionsFromEnv, + parseDatabaseLogging, + slowQueryThresholdFromEnv, +} from './database-logging.config'; +import { DatabaseLogger } from './database.logger'; export default new DataSource({ type: 'mysql', @@ -17,7 +23,9 @@ export default new DataSource({ password: process.env.DB_PASSWORD ?? '', database: process.env.DB_DATABASE ?? 'listify', synchronize: false, - logging: process.env.DB_LOGGING === 'true', + logging: parseDatabaseLogging(process.env.DB_LOGGING), + logger: new DatabaseLogger(databaseLoggerOptionsFromEnv(process.env)), + maxQueryExecutionTime: slowQueryThresholdFromEnv(process.env), entities: [ UserEntity, RefreshTokenEntity, diff --git a/listify-api/src/database/database-logging.config.ts b/listify-api/src/database/database-logging.config.ts new file mode 100644 index 0000000..a907ee5 --- /dev/null +++ b/listify-api/src/database/database-logging.config.ts @@ -0,0 +1,53 @@ +import type { LoggerOptions } from 'typeorm'; + +export interface DatabaseLoggerOptions { + includeParameters: boolean; + maxParameterLength: number; +} + +const ALL_LOGGING_OPTIONS: LoggerOptions = [ + 'query', + 'error', + 'schema', + 'warn', + 'info', + 'log', + 'migration', +]; + +export function parseDatabaseLogging(value?: string | boolean): LoggerOptions { + if (typeof value === 'boolean') { + return value ? ALL_LOGGING_OPTIONS : false; + } + + const normalizedValue = value?.trim().toLowerCase(); + + if (!normalizedValue || normalizedValue === 'false' || normalizedValue === 'off') { + return false; + } + + if (normalizedValue === 'true' || normalizedValue === 'all') { + return ALL_LOGGING_OPTIONS; + } + + return normalizedValue + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) as LoggerOptions; +} + +export function databaseLoggerOptionsFromEnv( + values: Record, +): DatabaseLoggerOptions { + return { + includeParameters: values.DB_LOG_PARAMETERS === 'true', + maxParameterLength: Number(values.DB_LOG_MAX_PARAM_LENGTH ?? '500'), + }; +} + +export function slowQueryThresholdFromEnv( + values: Record, +): number | undefined { + const threshold = Number(values.DB_SLOW_QUERY_THRESHOLD_MS ?? '500'); + return Number.isFinite(threshold) && threshold > 0 ? threshold : undefined; +} diff --git a/listify-api/src/database/database.logger.ts b/listify-api/src/database/database.logger.ts new file mode 100644 index 0000000..e0b62c1 --- /dev/null +++ b/listify-api/src/database/database.logger.ts @@ -0,0 +1,126 @@ +import { Logger as NestLogger } from '@nestjs/common'; +import type { Logger as TypeOrmLogger, QueryRunner } from 'typeorm'; +import type { DatabaseLoggerOptions } from './database-logging.config'; + +const REDACTED = '[redacted]'; +const SENSITIVE_KEY_PATTERN = /password|token|secret|authorization|cookie|hash/i; + +export class DatabaseLogger implements TypeOrmLogger { + private readonly logger = new NestLogger('Database'); + + constructor(private readonly options: DatabaseLoggerOptions) {} + + logQuery( + query: string, + parameters?: unknown[], + queryRunner?: QueryRunner, + ): void { + this.logger.debug(this.formatMessage('query', query, parameters, queryRunner)); + } + + logQueryError( + error: string | Error, + query: string, + parameters?: unknown[], + queryRunner?: QueryRunner, + ): void { + const errorMessage = error instanceof Error ? error.message : error; + const errorStack = error instanceof Error ? error.stack : undefined; + this.logger.error( + this.formatMessage('query-error', query, parameters, queryRunner, { + error: errorMessage, + }), + errorStack, + ); + } + + logQuerySlow( + time: number, + query: string, + parameters?: unknown[], + queryRunner?: QueryRunner, + ): void { + this.logger.warn( + this.formatMessage('slow-query', query, parameters, queryRunner, { + durationMs: time, + }), + ); + } + + logSchemaBuild(message: string, queryRunner?: QueryRunner): void { + this.logger.log(this.formatMessage('schema', message, undefined, queryRunner)); + } + + logMigration(message: string, queryRunner?: QueryRunner): void { + this.logger.log(this.formatMessage('migration', message, undefined, queryRunner)); + } + + log( + level: 'log' | 'info' | 'warn', + message: string, + queryRunner?: QueryRunner, + ): void { + const formattedMessage = this.formatMessage(level, message, undefined, queryRunner); + + if (level === 'warn') { + this.logger.warn(formattedMessage); + return; + } + + if (level === 'info') { + this.logger.debug(formattedMessage); + return; + } + + this.logger.log(formattedMessage); + } + + private formatMessage( + category: string, + message: string, + parameters?: unknown[], + queryRunner?: QueryRunner, + extra: Record = {}, + ): string { + const payload = { + category, + database: queryRunner?.connection.options.database, + message: this.compactWhitespace(message), + ...extra, + ...(this.options.includeParameters && parameters?.length + ? { parameters: this.sanitizeValue(parameters) } + : {}), + }; + + return JSON.stringify(payload); + } + + private sanitizeValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => this.sanitizeValue(entry)); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + SENSITIVE_KEY_PATTERN.test(key) ? REDACTED : this.sanitizeValue(entry), + ]), + ); + } + + if (typeof value === 'string' && value.length > this.options.maxParameterLength) { + return `${value.slice(0, this.options.maxParameterLength)}...`; + } + + return value; + } + + private compactWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); + } +}