diff --git a/server/src/bin/sync-sql.ts b/server/src/bin/sync-sql.ts index 4765993643..a9f5d72ec9 100644 --- a/server/src/bin/sync-sql.ts +++ b/server/src/bin/sync-sql.ts @@ -6,6 +6,7 @@ import { Test } from '@nestjs/testing'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ClassConstructor } from 'class-transformer'; import { PostgresJSDialect } from 'kysely-postgres-js'; +import { ClsModule } from 'nestjs-cls'; import { KyselyModule } from 'nestjs-kysely'; import { OpenTelemetryModule } from 'nestjs-otel'; import { mkdir, rm, writeFile } from 'node:fs/promises'; @@ -77,7 +78,7 @@ class SqlGenerator { await mkdir(this.options.targetDir); process.env.DB_HOSTNAME = 'localhost'; - const { database, otel } = new ConfigRepository().getEnv(); + const { database, cls, otel } = new ConfigRepository().getEnv(); const moduleFixture = await Test.createTestingModule({ imports: [ @@ -92,6 +93,7 @@ class SqlGenerator { } }, }), + ClsModule.forRoot(cls.config), TypeOrmModule.forRoot({ ...database.config.typeorm, entities, diff --git a/server/src/repositories/logging.repository.spec.ts b/server/src/repositories/logging.repository.spec.ts index 10c1a6516c..393eeb9496 100644 --- a/server/src/repositories/logging.repository.spec.ts +++ b/server/src/repositories/logging.repository.spec.ts @@ -1,8 +1,8 @@ import { ClsService } from 'nestjs-cls'; import { ImmichWorker } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; -import { LoggingRepository } from 'src/repositories/logging.repository'; -import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; +import { LoggingRepository, MyConsoleLogger } from 'src/repositories/logging.repository'; +import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { Mocked } from 'vitest'; describe(LoggingRepository.name, () => { @@ -18,23 +18,25 @@ describe(LoggingRepository.name, () => { } as unknown as Mocked; }); - describe('formatContext', () => { - it('should use colors', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: false })); + describe(MyConsoleLogger.name, () => { + describe('formatContext', () => { + it('should use colors', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: true }); - expect(sut['formatContext']('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); - }); + expect(logger.formatContext('context')).toBe('\u001B[33m[Api:context]\u001B[39m '); + }); - it('should not use colors when noColor is true', () => { - configMock.getEnv.mockReturnValue(mockEnvData({ noColor: true })); + it('should not use colors when color is false', () => { + sut = new LoggingRepository(clsMock, configMock); + sut.setAppName(ImmichWorker.API); - sut = new LoggingRepository(clsMock, configMock); - sut.setAppName(ImmichWorker.API); + const logger = new MyConsoleLogger(clsMock, { color: false }); - expect(sut['formatContext']('context')).toBe('[Api:context] '); + expect(logger.formatContext('context')).toBe('[Api:context] '); + }); }); }); }); diff --git a/server/src/repositories/logging.repository.ts b/server/src/repositories/logging.repository.ts index 7ddae26a9d..801f467a6d 100644 --- a/server/src/repositories/logging.repository.ts +++ b/server/src/repositories/logging.repository.ts @@ -5,6 +5,9 @@ import { Telemetry } from 'src/decorators'; import { LogLevel } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; +type LogDetails = any[]; +type LogFunction = () => string; + const LOG_LEVELS = [LogLevel.VERBOSE, LogLevel.DEBUG, LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; enum LogColor { @@ -16,38 +19,22 @@ enum LogColor { CYAN_BRIGHT = 96, } -@Injectable({ scope: Scope.TRANSIENT }) -@Telemetry({ enabled: false }) -export class LoggingRepository extends ConsoleLogger { - private static logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; - private noColor: boolean; +let appName: string | undefined; +let logLevels: LogLevel[] = [LogLevel.LOG, LogLevel.WARN, LogLevel.ERROR, LogLevel.FATAL]; + +export class MyConsoleLogger extends ConsoleLogger { + private isColorEnabled: boolean; constructor( private cls: ClsService, - configRepository: ConfigRepository, + options?: { color?: boolean; context?: string }, ) { - super(LoggingRepository.name); - - const { noColor } = configRepository.getEnv(); - this.noColor = noColor; + super(options?.context || MyConsoleLogger.name); + this.isColorEnabled = options?.color || false; } - private static appName?: string = undefined; - - setAppName(name: string): void { - LoggingRepository.appName = name.charAt(0).toUpperCase() + name.slice(1); - } - - isLevelEnabled(level: LogLevel) { - return isLogLevelEnabled(level, LoggingRepository.logLevels); - } - - setLogLevel(level: LogLevel | false): void { - LoggingRepository.logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; - } - - protected formatContext(context: string): string { - let prefix = LoggingRepository.appName || ''; + formatContext(context: string): string { + let prefix = appName || ''; if (context) { prefix += (prefix ? ':' : '') + context; } @@ -74,6 +61,105 @@ export class LoggingRepository extends ConsoleLogger { }; private withColor(text: string, color: LogColor) { - return this.noColor ? text : `\u001B[${color}m${text}\u001B[39m`; + return this.isColorEnabled ? `\u001B[${color}m${text}\u001B[39m` : text; + } +} + +@Injectable({ scope: Scope.TRANSIENT }) +@Telemetry({ enabled: false }) +export class LoggingRepository { + private logger: MyConsoleLogger; + + constructor(cls: ClsService, configRepository: ConfigRepository) { + const { noColor } = configRepository.getEnv(); + this.logger = new MyConsoleLogger(cls, { context: LoggingRepository.name, color: !noColor }); + } + + setAppName(name: string): void { + appName = name.charAt(0).toUpperCase() + name.slice(1); + } + + setContext(context: string) { + this.logger.setContext(context); + } + + isLevelEnabled(level: LogLevel) { + return isLogLevelEnabled(level, logLevels); + } + + setLogLevel(level: LogLevel | false): void { + logLevels = level ? LOG_LEVELS.slice(LOG_LEVELS.indexOf(level)) : []; + } + + verbose(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.VERBOSE, message, details); + } + + verboseFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.VERBOSE, message, details); + } + + debug(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.DEBUG, message, details); + } + + debugFn(message: LogFunction, ...details: LogDetails) { + this.handleFunction(LogLevel.DEBUG, message, details); + } + + log(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.LOG, message, details); + } + + warn(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.WARN, message, details); + } + + error(message: string | Error, ...details: LogDetails) { + this.handleMessage(LogLevel.ERROR, message, details); + } + + fatal(message: string, ...details: LogDetails) { + this.handleMessage(LogLevel.FATAL, message, details); + } + + private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { + if (this.isLevelEnabled(level)) { + this.handleMessage(level, message(), details); + } + } + + private handleMessage(level: LogLevel, message: string | Error, details: LogDetails[]) { + switch (level) { + case LogLevel.VERBOSE: { + this.logger.verbose(message, ...details); + break; + } + + case LogLevel.DEBUG: { + this.logger.debug(message, ...details); + break; + } + + case LogLevel.LOG: { + this.logger.log(message, ...details); + break; + } + + case LogLevel.WARN: { + this.logger.warn(message, ...details); + break; + } + + case LogLevel.ERROR: { + this.logger.error(message, ...details); + break; + } + + case LogLevel.FATAL: { + this.logger.fatal(message, ...details); + break; + } + } } } diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 965e7ffd13..442225f7c8 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -10,7 +10,7 @@ import { citiesFile } from 'src/constants'; import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity'; -import { LogLevel, SystemMetadataKey } from 'src/enum'; +import { SystemMetadataKey } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; @@ -137,9 +137,7 @@ export class MapRepository { .executeTakeFirst(); if (response) { - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(response, null, 2)}`); const { countryCode, name: city, admin1Name } = response; const country = getName(countryCode, 'en') ?? null; @@ -167,9 +165,8 @@ export class MapRepository { return { country: null, state: null, city: null }; } - if (this.logger.isLevelEnabled(LogLevel.VERBOSE)) { - this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); - } + this.logger.verboseFn(() => `Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`); + const { admin_a3 } = ne_response; const country = getName(admin_a3, 'en') ?? null; const state = null; diff --git a/server/src/repositories/notification.repository.spec.ts b/server/src/repositories/notification.repository.spec.ts index 7707069dd9..6253697087 100644 --- a/server/src/repositories/notification.repository.spec.ts +++ b/server/src/repositories/notification.repository.spec.ts @@ -1,16 +1,11 @@ -import { LoggingRepository } from 'src/repositories/logging.repository'; import { EmailRenderRequest, EmailTemplate, NotificationRepository } from 'src/repositories/notification.repository'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { Mocked } from 'vitest'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; describe(NotificationRepository.name, () => { let sut: NotificationRepository; - let loggerMock: Mocked; beforeEach(() => { - loggerMock = newLoggingRepositoryMock() as ILoggingRepository as Mocked; - - sut = new NotificationRepository(loggerMock as LoggingRepository); + sut = new NotificationRepository(newFakeLoggingRepository()); }); describe('renderEmail', () => { diff --git a/server/src/repositories/storage.repository.spec.ts b/server/src/repositories/storage.repository.spec.ts index 3ab9e615ec..93b21a7f9b 100644 --- a/server/src/repositories/storage.repository.spec.ts +++ b/server/src/repositories/storage.repository.spec.ts @@ -1,9 +1,7 @@ import mockfs from 'mock-fs'; import { CrawlOptionsDto } from 'src/dtos/library.dto'; -import { LoggingRepository } from 'src/repositories/logging.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; -import { Mocked } from 'vitest'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; interface Test { test: string; @@ -182,11 +180,9 @@ const tests: Test[] = [ describe(StorageRepository.name, () => { let sut: StorageRepository; - let logger: Mocked; beforeEach(() => { - logger = newLoggingRepositoryMock(); - sut = new StorageRepository(logger as ILoggingRepository as LoggingRepository); + sut = new StorageRepository(newFakeLoggingRepository()); }); afterEach(() => { diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index c0c7a00ae7..ca1d9e7921 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -54,7 +54,7 @@ export class StorageService extends BaseService { this.logger.log('Successfully verified system mount folder checks'); } catch (error) { if (envData.storage.ignoreMountCheckErrors) { - this.logger.error(error); + this.logger.error(error as Error); this.logger.warn('Ignoring mount folder errors'); } else { throw error; diff --git a/server/test/medium/specs/metadata.service.spec.ts b/server/test/medium/specs/metadata.service.spec.ts index 4c89ce4e37..275d3f1bda 100644 --- a/server/test/medium/specs/metadata.service.spec.ts +++ b/server/test/medium/specs/metadata.service.spec.ts @@ -3,15 +3,12 @@ import { writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AssetEntity } from 'src/entities/asset.entity'; -import { LoggingRepository } from 'src/repositories/logging.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; import { MetadataService } from 'src/services/metadata.service'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newFakeLoggingRepository } from 'test/repositories/logger.repository.mock'; import { newRandomImage, newTestService, ServiceMocks } from 'test/utils'; -const metadataRepository = new MetadataRepository( - newLoggingRepositoryMock() as ILoggingRepository as LoggingRepository, -); +const metadataRepository = new MetadataRepository(newFakeLoggingRepository()); const createTestFile = async (exifData: Record) => { const data = newRandomImage(); diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 46a81c8965..7257d375f1 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -1,31 +1,23 @@ import { LoggingRepository } from 'src/repositories/logging.repository'; +import { RepositoryInterface } from 'src/types'; import { Mocked, vitest } from 'vitest'; -export type ILoggingRepository = Pick< - LoggingRepository, - | 'verbose' - | 'log' - | 'debug' - | 'warn' - | 'error' - | 'fatal' - | 'isLevelEnabled' - | 'setLogLevel' - | 'setContext' - | 'setAppName' ->; - -export const newLoggingRepositoryMock = (): Mocked => { +export const newLoggingRepositoryMock = (): Mocked> => { return { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), isLevelEnabled: vitest.fn(), verbose: vitest.fn(), + verboseFn: vitest.fn(), debug: vitest.fn(), + debugFn: vitest.fn(), log: vitest.fn(), warn: vitest.fn(), error: vitest.fn(), fatal: vitest.fn(), }; }; + +export const newFakeLoggingRepository = () => + newLoggingRepositoryMock() as RepositoryInterface as LoggingRepository; diff --git a/server/test/utils.ts b/server/test/utils.ts index 8f65ec614d..8b3798b8b1 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -63,7 +63,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock'; -import { ILoggingRepository, newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newLoggingRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock'; import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; @@ -120,7 +120,7 @@ export type ServiceMocks = { event: Mocked>; job: Mocked>; library: Mocked>; - logger: Mocked; + logger: Mocked>; machineLearning: Mocked>; map: Mocked>; media: Mocked>; @@ -197,7 +197,7 @@ export const newTestService = ( const viewMock = newViewRepositoryMock(); const sut = new Service( - loggerMock as ILoggingRepository as LoggingRepository, + loggerMock as RepositoryInterface as LoggingRepository, accessMock as IAccessRepository as AccessRepository, activityMock as RepositoryInterface as ActivityRepository, auditMock as RepositoryInterface as AuditRepository,