@nivinjoseph/n-log
Version:
Logging framework
193 lines (167 loc) • 6.43 kB
text/typescript
import { given } from "@nivinjoseph/n-defensive";
import { Exception } from "@nivinjoseph/n-exception";
import "@nivinjoseph/n-ext";
import { Duration, Make, Mutex } from "@nivinjoseph/n-util";
import Fs from "node:fs";
import Path from "node:path";
import { BaseLogger } from "./base-logger.js";
import { FileLoggerConfig } from "./file-logger-config.js";
import { LogPrefix } from "./log-prefix.js";
import { LogRecord } from "./log-record.js";
/**
* Logger implementation that writes logs to files.
* Features:
* - Logs are written to files named by hour (YYYY-MM-DD-HH.log)
* - Supports both plain text and JSON formatting
* - Automatic log file rotation
* - Configurable log retention period
* - Thread-safe writing using mutex
* - Debug logs only written in development environment
*/
export class FileLogger extends BaseLogger
{
private readonly _mutex = new Mutex();
private readonly _logDirPath: string;
private readonly _retentionDays: number;
private _lastPurgedAt = 0;
/**
* Creates a new instance of FileLogger
* @param config - Configuration for the file logger
* @param config.logDirPath - Absolute path to the directory where log files will be stored
* @param config.retentionDays - Number of days to retain log files before automatic deletion
* @param config.logDateTimeZone - Timezone for log timestamps (default: UTC)
* @param config.useJsonFormat - Whether to format logs as JSON (default: false)
*/
public constructor(config: FileLoggerConfig)
{
super(config);
const { logDirPath, retentionDays } = config;
given(logDirPath, "logDirPath").ensureHasValue().ensureIsString()
.ensure(t => Path.isAbsolute(t), "must be absolute");
given(retentionDays, "retentionDays").ensureHasValue().ensureIsNumber().ensure(t => t > 0);
this._retentionDays = Number.parseInt(retentionDays.toString());
if (!Fs.existsSync(logDirPath))
Fs.mkdirSync(logDirPath);
this._logDirPath = logDirPath;
}
/**
* Logs a debug message to a file.
* Only writes in development environment.
* @param debug - The debug message to log
* @returns A promise that resolves when the log is written
*/
public async logDebug(debug: string): Promise<void>
{
if (this.env === "dev")
await this._writeToLog(LogPrefix.debug, debug);
}
/**
* Logs an informational message to a file
* @param info - The informational message to log
* @returns A promise that resolves when the log is written
*/
public async logInfo(info: string): Promise<void>
{
await this._writeToLog(LogPrefix.info, info);
}
/**
* Logs a warning message or exception to a file
* @param warning - The warning message or exception to log
* @returns A promise that resolves when the log is written
*/
public async logWarning(warning: string | Exception): Promise<void>
{
await this._writeToLog(LogPrefix.warning, this.getErrorMessage(warning));
}
/**
* Logs an error message or exception to a file
* @param error - The error message or exception to log
* @returns A promise that resolves when the log is written
*/
public async logError(error: string | Exception): Promise<void>
{
await this._writeToLog(LogPrefix.error, this.getErrorMessage(error));
}
/**
* Writes a log message to the appropriate log file
* @param status - The log level/status
* @param message - The message to log
* @returns A promise that resolves when the log is written
*/
private async _writeToLog(status: LogPrefix, message: string): Promise<void>
{
given(status, "status").ensureHasValue().ensureIsEnum(LogPrefix);
given(message, "message").ensureHasValue().ensureIsString();
const dateTimeValue = this.getDateTime();
if (this.useJsonFormat)
{
let level = "";
switch (status)
{
case LogPrefix.debug:
level = "Debug";
break;
case LogPrefix.info:
level = "Info";
break;
case LogPrefix.warning:
level = "Warn";
break;
case LogPrefix.error:
level = "Error";
break;
}
let log: LogRecord = {
source: this.source,
service: this.service,
env: this.env,
level: level,
message,
...dateTimeValue
};
this.injectTrace(log, level === "Error");
if (this.logInjector)
log = this.logInjector(log);
message = JSON.stringify(log);
}
else
{
message = `${dateTimeValue.dateTime} ${status} ${message}`;
}
const logFileName = `${dateTimeValue.dateTime.substr(0, 13)}.log`;
const logFilePath = Path.join(this._logDirPath, logFileName);
await this._mutex.lock();
try
{
await Fs.promises.appendFile(logFilePath, `\n${message}`);
await this._purgeLogs();
}
catch (error)
{
console.error(error);
}
finally
{
this._mutex.release();
}
}
/**
* Purges log files older than the retention period
* @returns A promise that resolves when the purge is complete
*/
private async _purgeLogs(): Promise<void>
{
const now = Date.now();
if (this._lastPurgedAt && this._lastPurgedAt > (now - Duration.fromDays(this._retentionDays).toMilliSeconds()))
return;
const files = await Make.callbackToPromise<ReadonlyArray<string>>(Fs.readdir)(this._logDirPath);
await files.forEachAsync(async (file) =>
{
const filePath = Path.join(this._logDirPath, file);
const stats = await Make.callbackToPromise<Fs.Stats>(Fs.stat)(filePath);
if (stats.isFile() && stats.birthtimeMs < (now - Duration.fromDays(this._retentionDays).toMilliSeconds()))
await Make.callbackToPromise(Fs.unlink)(filePath);
}, 1);
this._lastPurgedAt = now;
}
}