Files
tunarr/server/src/util/logging/LoggerFactory.ts
2026-04-04 08:19:49 -04:00

334 lines
9.2 KiB
TypeScript

import type { SettingsDB } from '@/db/SettingsDB.js';
import type { Maybe, TupleToUnion } from '@/types/util.js';
import { getDefaultLogLevel } from '@/util/defaults.js';
import { inConstArr, isNonEmptyString, isTest } from '@/util/index.js';
import {
forEach,
isEmpty,
isEqual,
isUndefined,
toLower,
trim,
} from 'lodash-es';
import { join } from 'node:path';
import type {
Bindings,
ChildLoggerOptions,
MultiStreamRes,
StreamEntry,
} from 'pino';
import pino, {
levels,
multistream,
symbols,
type LevelWithSilent,
type Logger as PinoLogger,
} from 'pino';
import type { PrettyOptions } from 'pino-pretty';
import pretty from 'pino-pretty';
import { TUNARR_ENV_VARS } from '../env.ts';
import type { SerializedLogger } from './LoggerWrapper.ts';
import { RootLoggerWrapper } from './LoggerWrapper.ts';
import { RollingLogDestination } from './RollingDestination.ts';
export function getEnvironmentLogLevel(envVar?: string): Maybe<LogLevels> {
const envLevel = trim(
toLower(process.env[envVar ?? TUNARR_ENV_VARS.LOG_LEVEL_ENV_VAR]),
);
if (isNonEmptyString(envLevel)) {
if (ValidLogLevels.includes(envLevel)) {
return envLevel as LogLevels;
} else {
console.warn(
`Invalid log level provided in env var: %s. Ignoring`,
envLevel,
);
}
}
return;
}
const ExtraLogLevels = ['http', 'http_out'] as const;
export const ValidLogLevels = [
...Object.keys(levels.values),
...ExtraLogLevels,
];
export type ExtraLogLevels = TupleToUnion<typeof ExtraLogLevels>;
export type Logger = PinoLogger<ExtraLogLevels>;
export type LogLevels = LevelWithSilent | ExtraLogLevels;
export type GetChildLoggerArgs = {
caller?: ImportMeta | string;
category?: LogCategory;
className: string;
} & Bindings;
export function getPrettyStreamOpts(): PrettyOptions {
return {
// minimumLevel: logLevel === 'silent' ? undefined : logLevel,
translateTime: "SYS:yyyy-mm-dd'T'HH:MM:ss.l'Z'",
singleLine: true,
ignore: 'pid,hostname',
customLevels: {
http: 25,
},
customColors: {
http: 'blue',
},
useOnlyCustomProps: false,
messageFormat: (log, messageKey, _, { colors }) => {
return `${colors.white(log[messageKey] as string)}`;
},
customPrettifiers: {
time: (t) => {
return t as string;
},
level: (_level, _key, _log, { labelColorized }) => {
return `[${labelColorized.toLowerCase()}]`;
},
caller: (caller, _key, _log, { colors }) => {
return colors.green(caller as string);
},
},
colorize: true,
};
}
export const LogCategories = ['streaming', 'scheduling'] as const;
export type LogCategory = TupleToUnion<typeof LogCategories>;
class LoggerFactoryImpl {
private settingsDB?: SettingsDB;
// private rootLogger: PinoLogger<ExtraLogLevels>;
private rootLogger!: RootLoggerWrapper;
private initialized = false;
private children: Record<string, WeakRef<Logger>> = {};
private currentStreams?: MultiStreamRes<LogLevels>;
private roller?: RollingLogDestination;
constructor() {
// This ensures we always have a logger with the default configuration.
// Once settings are initialized we update this root and all children
// loggers.
this.rootLogger = this.createRootLogger();
}
initialize(settingsDB: SettingsDB) {
if (!this.initialized) {
this.settingsDB = settingsDB;
// We're not going to mess with multi-threaded transports right now
// but it does seem to work with relative paths + the shim... so I'm
// going to keep them around for now.
this.rootLogger = this.createRootLogger();
this.settingsDB.on('change', (prevSettings) => {
if (!this.initialized) {
return;
}
const currentSettings =
this.settingsDB?.systemSettings().logging.logRollConfig;
const { level: newLevel } = this.logLevel;
const perCategoryLogLevel = this.perCategoryLogLevel;
if (
this.rootLogger.logger[symbols.getLevelSym] !== newLevel ||
!prevSettings ||
!isEqual(prevSettings.system.logging.logRollConfig, currentSettings)
) {
this.updateLevel(newLevel);
setTimeout(() => {
this.rollLogsNow();
});
}
for (const [category, level] of Object.entries(perCategoryLogLevel)) {
if (!inConstArr(LogCategories, category)) {
continue;
}
this.rootLogger.updateCategoryLevel(level, category, () =>
this.createLogStreams(level),
);
}
});
if (!isEmpty(this.children)) {
forEach(this.children, (child) => {
if (child[symbols.streamSym] !== this.currentStreams) {
Object.assign(child[symbols.streamSym], this.currentStreams);
}
});
}
this.initialized = true;
}
}
get root() {
return this.rootLogger.logger;
}
get isInitialized() {
return this.initialized;
}
traverseHierarchy(): Generator<readonly [string, SerializedLogger]> {
return this.rootLogger.traverseHierarchy();
}
rollLogsNow() {
this.roller?.roll();
}
child(
args: GetChildLoggerArgs,
opts?: ChildLoggerOptions<LogLevels>,
): Logger {
const { className } = args;
opts ??= {};
const levelOverride = getEnvironmentLogLevel(
`TUNARR_LOG_LEVEL_${className.toUpperCase()}`,
);
const child = this.rootLogger.child(args, opts);
if (levelOverride) {
child.updateStreams(multistream(this.createStreams(levelOverride)));
}
return child.logger;
}
private createLogStreams(level?: LogLevels) {
const { level: settingsLevel } = this.logLevel;
this.currentStreams = multistream(
this.createStreams(level ?? settingsLevel),
);
return this.currentStreams;
}
private createRootLogger(): RootLoggerWrapper {
const { level } = this.logLevel;
const root = pino(
{
level,
customLevels: {
http: 25, // Finer than info but not as fine as debug
http_out: 15, // Finder than debug but not as fine as trace
},
},
this.createLogStreams(),
);
return new RootLoggerWrapper(
root,
this.settingsDB?.systemSettings().logging,
);
}
private get logLevel(): {
level: LogLevels;
source: 'env' | 'settings';
} {
if (this.settingsDB?.systemSettings().logging.useEnvVarLevel) {
const envLevel = getEnvironmentLogLevel();
if (!isUndefined(envLevel)) {
return { source: 'env', level: envLevel };
}
}
return { level: this.systemSettingsLogLevel, source: 'settings' };
}
private get perCategoryLogLevel(): Record<string, LogLevels> {
if (!this.settingsDB) {
return {};
}
return this.settingsDB.systemSettings().logging.categoryLogLevel ?? {};
}
private get systemSettingsLogLevel() {
if (!isUndefined(this.settingsDB)) {
return this.settingsDB.systemSettings().logging
.logLevel as LevelWithSilent;
} else {
return getDefaultLogLevel();
}
}
private updateLevel(newLevel: LogLevels, category?: string) {
if (category && inConstArr(LogCategories, category)) {
this.rootLogger.updateCategoryLevel(newLevel, category, () =>
this.createLogStreams(newLevel),
);
return;
}
// Reset the level of the root logger and all children.
// We set the level on every logger instance directly because pino children
// snapshot the parent's level at creation time and don't follow changes.
// For multistream to work, we also have to manually reset the streams.
this.rootLogger.updateLevel(newLevel, this.createLogStreams(newLevel));
}
private createStreams(logLevel: LogLevels): StreamEntry<LogLevels>[] {
const streams: StreamEntry<LogLevels>[] = [
{
stream: pretty(getPrettyStreamOpts()),
level: logLevel,
},
];
// We can only add these streams post-initialization because they
// require configuration.
if (!isUndefined(this.settingsDB) && !isTest) {
// TODO Expose this in the UI with configuration
const logConfig = this.settingsDB.systemSettings().logging;
const logFilePath = join(logConfig.logsDirectory, 'tunarr.log');
this.roller?.deinitialize();
this.roller = undefined;
if (logConfig.logRollConfig.enabled) {
this.roller = new RollingLogDestination({
fileName: logFilePath,
maxSizeBytes: logConfig.logRollConfig.maxFileSizeBytes,
rotateSchedule: logConfig.logRollConfig.schedule,
fileLimit: {
count: logConfig.logRollConfig.rolledFileLimit,
},
destinationOpts: {
mkdir: true,
append: true,
},
});
streams.push({
stream: this.roller.initDestination(),
level: logLevel,
});
} else {
streams.push({
stream: pino.destination({
dest: join(
this.settingsDB.systemSettings().logging.logsDirectory,
'tunarr.log',
),
mkdir: true,
append: true,
}),
level: logLevel,
});
}
}
return streams;
}
}
export const LoggerFactory = new LoggerFactoryImpl();
export const RootLogger = LoggerFactory.root;