This commit is contained in:
Bastian Wagner
2026-06-09 14:09:34 +02:00
parent 4c9ecb0b6b
commit 06c5b1768e
6 changed files with 231 additions and 19 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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<string>('DB_HOST', 'localhost'),
port: Number(configService.get<string>('DB_PORT', '3306')),
username: configService.get<string>('DB_USERNAME', 'root'),
password: configService.get<string>('DB_PASSWORD', ''),
database: configService.get<string>('DB_DATABASE', 'listify'),
autoLoadEntities: true,
synchronize: false,
logging: configService.get<string>('DB_LOGGING', 'false') === 'true',
}),
useFactory: (configService: ConfigService) => {
const env = {
DB_LOGGING: configService.get<string>('DB_LOGGING', 'false'),
DB_LOG_PARAMETERS: configService.get<string>('DB_LOG_PARAMETERS', 'false'),
DB_LOG_MAX_PARAM_LENGTH: configService.get<string>(
'DB_LOG_MAX_PARAM_LENGTH',
'500',
),
DB_SLOW_QUERY_THRESHOLD_MS: configService.get<string>(
'DB_SLOW_QUERY_THRESHOLD_MS',
'500',
),
};
return {
type: 'mysql',
host: configService.get<string>('DB_HOST', 'localhost'),
port: Number(configService.get<string>('DB_PORT', '3306')),
username: configService.get<string>('DB_USERNAME', 'root'),
password: configService.get<string>('DB_PASSWORD', ''),
database: configService.get<string>('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<string>('DB_HOST', 'localhost'))
}
}
export class AppModule {}

View File

@@ -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,

View File

@@ -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<string, string | undefined>,
): DatabaseLoggerOptions {
return {
includeParameters: values.DB_LOG_PARAMETERS === 'true',
maxParameterLength: Number(values.DB_LOG_MAX_PARAM_LENGTH ?? '500'),
};
}
export function slowQueryThresholdFromEnv(
values: Record<string, string | undefined>,
): number | undefined {
const threshold = Number(values.DB_SLOW_QUERY_THRESHOLD_MS ?? '500');
return Number.isFinite(threshold) && threshold > 0 ? threshold : undefined;
}

View File

@@ -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, unknown> = {},
): 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<string, unknown>).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();
}
}