feat(web,server)!: runtime log level (#5672)

* feat: change log level at runtime

* chore: open api

* chore: prefer env over runtime

* chore: remove default env value
This commit is contained in:
Jason Rasmussen
2023-12-14 11:55:40 -05:00
committed by GitHub
parent f2270ad757
commit 9768931275
61 changed files with 771 additions and 117 deletions

View File

@@ -1,5 +1,6 @@
import { AssetEntity, LibraryType } from '@app/infra/entities';
import { BadRequestException, Inject, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject } from '@nestjs/common';
import _ from 'lodash';
import { DateTime, Duration } from 'luxon';
import { extname } from 'path';
@@ -75,7 +76,7 @@ export interface UploadFile {
}
export class AssetService {
private logger = new Logger(AssetService.name);
private logger = new ImmichLogger(AssetService.name);
private access: AccessCore;
private configCore: SystemConfigCore;

View File

@@ -1,5 +1,6 @@
import { AssetPathType, DatabaseAction, PersonPathType, UserPathType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AccessCore, Permission } from '../access';
@@ -29,7 +30,7 @@ import {
@Injectable()
export class AuditService {
private access: AccessCore;
private logger = new Logger(AuditService.name);
private logger = new ImmichLogger(AuditService.name);
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,

View File

@@ -1,10 +1,10 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import {
BadRequestException,
Inject,
Injectable,
InternalServerErrorException,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import cookieParser from 'cookie';
@@ -68,7 +68,7 @@ interface OAuthProfile extends UserinfoResponse {
export class AuthService {
private access: AccessCore;
private configCore: SystemConfigCore;
private logger = new Logger(AuthService.name);
private logger = new ImmichLogger(AuthService.name);
private userCore: UserCore;
constructor(

View File

@@ -1,5 +1,5 @@
// TODO: remove nestjs references from domain
import { LogLevel } from '@nestjs/common';
import { LogLevel } from '@app/infra/entities';
import { ConfigModuleOptions } from '@nestjs/config';
import Joi from 'joi';
@@ -18,19 +18,11 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_PASSWORD: WHEN_DB_URL_SET,
DB_DATABASE_NAME: WHEN_DB_URL_SET,
DB_URL: Joi.string().optional(),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'),
LOG_LEVEL: Joi.string()
.optional()
.valid(...Object.values(LogLevel)),
MACHINE_LEARNING_PORT: Joi.number().optional(),
MICROSERVICES_PORT: Joi.number().optional(),
SERVER_PORT: Joi.number().optional(),
}),
};
export function getLogLevels() {
const LOG_LEVELS: LogLevel[] = ['verbose', 'debug', 'log', 'warn', 'error'];
let logLevel = process.env.LOG_LEVEL || 'log';
if (logLevel === 'simple') {
logLevel = 'log';
}
const logLevelIndex = LOG_LEVELS.indexOf(logLevel as LogLevel);
return logLevelIndex === -1 ? [] : LOG_LEVELS.slice(logLevelIndex);
}

View File

@@ -1,3 +1,4 @@
import { ImmichLogger } from '@app/infra/logger';
import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
import { ActivityService } from './activity';
import { AlbumService } from './album';
@@ -43,6 +44,7 @@ const providers: Provider[] = [
SystemConfigService,
TagService,
UserService,
ImmichLogger,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [SystemConfigService],

View File

@@ -1,5 +1,6 @@
import { AssetType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { mapAsset } from '../asset';
import {
ClientEvent,
@@ -18,7 +19,7 @@ import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from './job.dto'
@Injectable()
export class JobService {
private logger = new Logger(JobService.name);
private logger = new ImmichLogger(JobService.name);
private configCore: SystemConfigCore;
constructor(

View File

@@ -1,5 +1,5 @@
import { AssetType, LibraryType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { R_OK } from 'node:constants';
import { Stats } from 'node:fs';
import path from 'node:path';
@@ -10,6 +10,7 @@ import { mimeTypes } from '../domain.constant';
import { usePagination, validateCronExpression } from '../domain.util';
import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { ImmichLogger } from '@app/infra/logger';
import {
IAccessRepository,
IAssetRepository,
@@ -33,7 +34,7 @@ import {
@Injectable()
export class LibraryService {
readonly logger = new Logger(LibraryService.name);
readonly logger = new ImmichLogger(LibraryService.name);
private access: AccessCore;
private configCore: SystemConfigCore;

View File

@@ -7,7 +7,8 @@ import {
TranscodePolicy,
VideoCodec,
} from '@app/infra/entities';
import { Inject, Injectable, Logger, UnsupportedMediaTypeException } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
import {
@@ -39,7 +40,7 @@ import {
@Injectable()
export class MediaService {
private logger = new Logger(MediaService.name);
private logger = new ImmichLogger(MediaService.name);
private configCore: SystemConfigCore;
private storageCore: StorageCore;

View File

@@ -1,5 +1,6 @@
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { ExifDateTime, Tags } from 'exiftool-vendored';
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
import { constants } from 'fs/promises';
@@ -91,7 +92,7 @@ const validate = <T>(value: T): NonNullable<T> | null => {
@Injectable()
export class MetadataService {
private logger = new Logger(MetadataService.name);
private logger = new ImmichLogger(MetadataService.name);
private storageCore: StorageCore;
private configCore: SystemConfigCore;
private subscription: Subscription | null = null;

View File

@@ -1,6 +1,7 @@
import { PersonEntity } from '@app/infra/entities';
import { PersonPathType } from '@app/infra/entities/move.entity';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { AccessCore, Permission } from '../access';
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth';
@@ -45,7 +46,7 @@ export class PersonService {
private access: AccessCore;
private configCore: SystemConfigCore;
private storageCore: StorageCore;
readonly logger = new Logger(PersonService.name);
readonly logger = new ImmichLogger(PersonService.name);
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,

View File

@@ -1,5 +1,6 @@
import { AssetEntity } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from '../asset';
import { AuthDto } from '../auth';
import { PersonResponseDto } from '../person';
@@ -18,7 +19,7 @@ import { SearchResponseDto } from './response-dto';
@Injectable()
export class SearchService {
private logger = new Logger(SearchService.name);
private logger = new ImmichLogger(SearchService.name);
private configCore: SystemConfigCore;
constructor(

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { ServerVersion, isDev, mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
@@ -25,7 +26,7 @@ import {
@Injectable()
export class ServerInfoService {
private logger = new Logger(ServerInfoService.name);
private logger = new ImmichLogger(ServerInfoService.name);
private configCore: SystemConfigCore;
private releaseVersion = serverVersion;
private releaseVersionCheckedAt: DateTime | null = null;

View File

@@ -1,4 +1,5 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { setTimeout } from 'timers/promises';
import { usePagination } from '../domain.util';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job';
@@ -15,7 +16,7 @@ import { SystemConfigCore } from '../system-config';
@Injectable()
export class SmartInfoService {
private configCore: SystemConfigCore;
private logger = new Logger(SmartInfoService.name);
private logger = new ImmichLogger(SmartInfoService.name);
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,

View File

@@ -1,5 +1,6 @@
import { AssetEntity, AssetPathType, AssetType, SystemConfig } from '@app/infra/entities';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import handlebar from 'handlebars';
import * as luxon from 'luxon';
import path from 'node:path';
@@ -42,7 +43,7 @@ interface RenderMetadata {
@Injectable()
export class StorageTemplateService {
private logger = new Logger(StorageTemplateService.name);
private logger = new ImmichLogger(StorageTemplateService.name);
private configCore: SystemConfigCore;
private storageCore: StorageCore;
private template: {

View File

@@ -1,5 +1,5 @@
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
import { Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from '../domain.constant';
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
@@ -24,7 +24,7 @@ type GeneratedAssetPath = AssetPathType.JPEG_THUMBNAIL | AssetPathType.WEBP_THUM
let instance: StorageCore | null;
export class StorageCore {
private logger = new Logger(StorageCore.name);
private logger = new ImmichLogger(StorageCore.name);
private constructor(
private assetRepository: IAssetRepository,

View File

@@ -1,11 +1,12 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { IDeleteFilesJob } from '../job';
import { IStorageRepository } from '../repositories';
import { StorageCore, StorageFolder } from './storage.core';
@Injectable()
export class StorageService {
private logger = new Logger(StorageService.name);
private logger = new ImmichLogger(StorageService.name);
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {}

View File

@@ -0,0 +1,12 @@
import { LogLevel } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsEnum } from 'class-validator';
export class SystemConfigLoggingDto {
@IsBoolean()
enabled!: boolean;
@ApiProperty({ enum: LogLevel, enumName: 'LogLevel' })
@IsEnum(LogLevel)
level!: LogLevel;
}

View File

@@ -4,6 +4,7 @@ import { IsObject, ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigJobDto } from './system-config-job.dto';
import { SystemConfigLibraryDto } from './system-config-library.dto';
import { SystemConfigLoggingDto } from './system-config-logging.dto';
import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto';
import { SystemConfigMapDto } from './system-config-map.dto';
import { SystemConfigNewVersionCheckDto } from './system-config-new-version-check.dto';
@@ -21,6 +22,11 @@ export class SystemConfigDto implements SystemConfig {
@IsObject()
ffmpeg!: SystemConfigFFmpegDto;
@Type(() => SystemConfigLoggingDto)
@ValidateNested()
@IsObject()
logging!: SystemConfigLoggingDto;
@Type(() => SystemConfigMachineLearningDto)
@ValidateNested()
@IsObject()

View File

@@ -2,6 +2,7 @@ import {
AudioCodec,
Colorspace,
CQMode,
LogLevel,
SystemConfig,
SystemConfigEntity,
SystemConfigKey,
@@ -11,7 +12,8 @@ import {
TranscodePolicy,
VideoCodec,
} from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { CronExpression } from '@nestjs/schedule';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
@@ -21,7 +23,7 @@ import { QueueName } from '../job/job.constants';
import { ISystemConfigRepository } from '../repositories';
import { SystemConfigDto } from './dto';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
export const defaults = Object.freeze<SystemConfig>({
ffmpeg: {
@@ -57,6 +59,10 @@ export const defaults = Object.freeze<SystemConfig>({
[QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 },
[QueueName.VIDEO_CONVERSION]: { concurrency: 1 },
},
logging: {
enabled: true,
level: LogLevel.LOG,
},
machineLearning: {
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
url: process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003',
@@ -149,7 +155,7 @@ let instance: SystemConfigCore | null;
@Injectable()
export class SystemConfigCore {
private logger = new Logger(SystemConfigCore.name);
private logger = new ImmichLogger(SystemConfigCore.name);
private validators: SystemConfigValidator[] = [];
private configCache: SystemConfigEntity<SystemConfigValue>[] | null = null;
@@ -253,14 +259,16 @@ export class SystemConfigCore {
return config;
}
public async updateConfig(config: SystemConfig): Promise<SystemConfig> {
public async updateConfig(newConfig: SystemConfig): Promise<SystemConfig> {
if (await this.hasFeature(FeatureFlag.CONFIG_FILE)) {
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
}
const oldConfig = await this.getConfig();
try {
for (const validator of this.validators) {
await validator(config);
await validator(newConfig, oldConfig);
}
} catch (e) {
this.logger.warn(`Unable to save system config due to a validation error: ${e}`);
@@ -272,9 +280,9 @@ export class SystemConfigCore {
for (const key of Object.values(SystemConfigKey)) {
// get via dot notation
const item = { key, value: _.get(config, key) as SystemConfigValue };
const item = { key, value: _.get(newConfig, key) as SystemConfigValue };
const defaultValue = _.get(defaults, key);
const isMissing = !_.has(config, key);
const isMissing = !_.has(newConfig, key);
if (
isMissing ||
@@ -298,11 +306,11 @@ export class SystemConfigCore {
await this.repository.deleteKeys(deletes.map((item) => item.key));
}
const newConfig = await this.getConfig();
const config = await this.getConfig();
this.config$.next(newConfig);
this.config$.next(config);
return newConfig;
return config;
}
public async refreshConfig() {

View File

@@ -2,6 +2,7 @@ import {
AudioCodec,
Colorspace,
CQMode,
LogLevel,
SystemConfig,
SystemConfigEntity,
SystemConfigKey,
@@ -57,6 +58,10 @@ const updatedConfig = Object.freeze<SystemConfig>({
accel: TranscodeHWAccel.DISABLED,
tonemap: ToneMapping.HABLE,
},
logging: {
enabled: true,
level: LogLevel.LOG,
},
machineLearning: {
enabled: true,
url: 'http://immich-machine-learning:3003',
@@ -159,7 +164,7 @@ describe(SystemConfigService.name, () => {
const validator: SystemConfigValidator = jest.fn();
sut.addValidator(validator);
await sut.updateConfig(defaults);
expect(validator).toHaveBeenCalledWith(defaults);
expect(validator).toHaveBeenCalledWith(defaults, defaults);
});
});
@@ -279,7 +284,7 @@ describe(SystemConfigService.name, () => {
await expect(sut.updateConfig(updatedConfig)).rejects.toBeInstanceOf(BadRequestException);
expect(validator).toHaveBeenCalledWith(updatedConfig);
expect(validator).toHaveBeenCalledWith(updatedConfig, defaults);
expect(configMock.saveAll).not.toHaveBeenCalled();
});

View File

@@ -1,4 +1,8 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { LogLevel, SystemConfig } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common';
import { instanceToPlain } from 'class-transformer';
import _ from 'lodash';
import {
ClientEvent,
ICommunicationRepository,
@@ -22,7 +26,7 @@ import { SystemConfigCore, SystemConfigValidator } from './system-config.core';
@Injectable()
export class SystemConfigService {
private logger = new Logger(SystemConfigService.name);
private logger = new ImmichLogger(SystemConfigService.name);
private core: SystemConfigCore;
constructor(
@@ -32,6 +36,13 @@ export class SystemConfigService {
) {
this.core = SystemConfigCore.create(repository);
this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate());
this.core.config$.subscribe((config) => this.setLogLevel(config));
this.core.addValidator((newConfig, oldConfig) => this.validateConfig(newConfig, oldConfig));
}
async init() {
const config = await this.core.getConfig();
await this.setLogLevel(config);
}
get config$() {
@@ -106,4 +117,22 @@ export class SystemConfigService {
private async handleConfigUpdate() {
await this.core.refreshConfig();
}
private async setLogLevel({ logging }: SystemConfig) {
const envLevel = this.getEnvLogLevel();
const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ? envLevel : configLevel;
ImmichLogger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via LOG_LEVEL)' : '(set via system config)'}`);
}
private getEnvLogLevel() {
return process.env.LOG_LEVEL as LogLevel;
}
private async validateConfig(newConfig: SystemConfig, oldConfig: SystemConfig) {
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) {
throw new Error('Logging cannot be changed while the environment variable LOG_LEVEL is set.');
}
}
}

View File

@@ -1,5 +1,6 @@
import { UserEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { randomBytes } from 'crypto';
import { AuthDto } from '../auth';
import { ImmichFileResponse } from '../domain.util';
@@ -21,7 +22,7 @@ import { UserCore } from './user.core';
@Injectable()
export class UserService {
private logger = new Logger(UserService.name);
private logger = new ImmichLogger(UserService.name);
private userCore: UserCore;
constructor(